diff --git a/src/app/api/source-search/categories/route.ts b/src/app/api/source-search/categories/route.ts new file mode 100644 index 0000000..daf157c --- /dev/null +++ b/src/app/api/source-search/categories/route.ts @@ -0,0 +1,77 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { getAuthInfoFromCookie } from '@/lib/auth'; +import { API_CONFIG, getAvailableApiSites } from '@/lib/config'; + +export const runtime = 'nodejs'; + +interface CmsClassResponse { + class?: Array<{ + type_id: string | number; + type_name: string; + }>; +} + +/** + * 获取指定视频源的分类列表 + */ +export async function GET(request: NextRequest) { + const authInfo = getAuthInfoFromCookie(request); + if (!authInfo || !authInfo.username) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const sourceKey = searchParams.get('source'); + + if (!sourceKey) { + return NextResponse.json( + { error: '缺少参数: source' }, + { status: 400 } + ); + } + + try { + const apiSites = await getAvailableApiSites(authInfo.username); + const targetSite = apiSites.find((site) => site.key === sourceKey); + + if (!targetSite) { + return NextResponse.json( + { error: `未找到指定的视频源: ${sourceKey}` }, + { status: 404 } + ); + } + + // 请求分类列表 + const classUrl = `${targetSite.api}?ac=list`; + const classResponse = await fetch(classUrl, { + headers: API_CONFIG.search.headers, + signal: AbortSignal.timeout(10000), + }); + + if (!classResponse.ok) { + throw new Error('获取分类列表失败'); + } + + const classData: CmsClassResponse = await classResponse.json(); + + if (!classData.class || !Array.isArray(classData.class)) { + return NextResponse.json({ + categories: [], + }); + } + + return NextResponse.json({ + categories: classData.class.map((item) => ({ + id: item.type_id.toString(), + name: item.type_name, + })), + }); + } catch (error) { + console.error('Failed to get categories:', error); + return NextResponse.json( + { error: '获取分类列表失败' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/source-search/search/route.ts b/src/app/api/source-search/search/route.ts new file mode 100644 index 0000000..eff5d41 --- /dev/null +++ b/src/app/api/source-search/search/route.ts @@ -0,0 +1,128 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { getAuthInfoFromCookie } from '@/lib/auth'; +import { API_CONFIG, getAvailableApiSites } from '@/lib/config'; +import { SearchResult } from '@/lib/types'; + +export const runtime = 'nodejs'; + +interface CmsVideoItem { + vod_id: string | number; + vod_name: string; + vod_pic: string; + vod_remarks?: string; + vod_year?: string; + vod_play_from?: string; + vod_play_url?: string; +} + +interface CmsVideoResponse { + list?: CmsVideoItem[]; + total?: number; + page?: number; + pagecount?: number; +} + +/** + * 在指定视频源中搜索视频 + */ +export async function GET(request: NextRequest) { + const authInfo = getAuthInfoFromCookie(request); + if (!authInfo || !authInfo.username) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const sourceKey = searchParams.get('source'); + const keyword = searchParams.get('keyword'); + const page = searchParams.get('page') || '1'; + + if (!sourceKey) { + return NextResponse.json( + { error: '缺少参数: source' }, + { status: 400 } + ); + } + + if (!keyword || keyword.trim() === '') { + return NextResponse.json( + { error: '缺少参数: keyword' }, + { status: 400 } + ); + } + + try { + const apiSites = await getAvailableApiSites(authInfo.username); + const targetSite = apiSites.find((site) => site.key === sourceKey); + + if (!targetSite) { + return NextResponse.json( + { error: `未找到指定的视频源: ${sourceKey}` }, + { status: 404 } + ); + } + + // 请求搜索结果 + const searchUrl = `${targetSite.api}?ac=videolist&wd=${encodeURIComponent(keyword)}&pg=${page}`; + const searchResponse = await fetch(searchUrl, { + headers: API_CONFIG.search.headers, + signal: AbortSignal.timeout(10000), + }); + + if (!searchResponse.ok) { + throw new Error('搜索失败'); + } + + const searchData: CmsVideoResponse = await searchResponse.json(); + + if (!searchData.list || !Array.isArray(searchData.list)) { + return NextResponse.json({ + results: [], + total: 0, + page: parseInt(page), + pageCount: 0, + }); + } + + // 转换为 SearchResult 格式 + const results: SearchResult[] = searchData.list.map((item) => { + const episodes: string[] = []; + const episodes_titles: string[] = []; + + // 解析播放信息 + if (item.vod_play_url && item.vod_play_from) { + const playUrls = item.vod_play_url.split('#'); + playUrls.forEach((episodeStr) => { + if (episodeStr.trim()) { + const [name, url] = episodeStr.split('$'); + if (name && url) { + episodes.push(url.trim()); + episodes_titles.push(name.trim()); + } + } + }); + } + + return { + id: item.vod_id.toString(), + title: item.vod_name, + poster: item.vod_pic || '', + year: item.vod_year || 'unknown', + episodes, + episodes_titles, + source: targetSite.key, + source_name: targetSite.name, + }; + }); + + return NextResponse.json({ + results, + total: searchData.total || 0, + page: parseInt(page), + pageCount: searchData.pagecount || 0, + }); + } catch (error) { + console.error('Failed to search videos:', error); + return NextResponse.json({ error: '搜索失败' }, { status: 500 }); + } +} diff --git a/src/app/api/source-search/sources/route.ts b/src/app/api/source-search/sources/route.ts new file mode 100644 index 0000000..1dc88c0 --- /dev/null +++ b/src/app/api/source-search/sources/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { getAuthInfoFromCookie } from '@/lib/auth'; +import { getAvailableApiSites } from '@/lib/config'; + +export const runtime = 'nodejs'; + +export async function GET(request: NextRequest) { + const authInfo = getAuthInfoFromCookie(request); + if (!authInfo || !authInfo.username) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + try { + const apiSites = await getAvailableApiSites(authInfo.username); + + return NextResponse.json({ + sources: apiSites.map((site) => ({ + key: site.key, + name: site.name, + api: site.api, + })), + }); + } catch (error) { + console.error('Failed to get available API sites:', error); + return NextResponse.json( + { error: 'Failed to load sources' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/source-search/videos/route.ts b/src/app/api/source-search/videos/route.ts new file mode 100644 index 0000000..e6e4f0c --- /dev/null +++ b/src/app/api/source-search/videos/route.ts @@ -0,0 +1,131 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { getAuthInfoFromCookie } from '@/lib/auth'; +import { API_CONFIG, getAvailableApiSites } from '@/lib/config'; +import { SearchResult } from '@/lib/types'; + +export const runtime = 'nodejs'; + +interface CmsVideoItem { + vod_id: string | number; + vod_name: string; + vod_pic: string; + vod_remarks?: string; + vod_year?: string; + vod_play_from?: string; + vod_play_url?: string; +} + +interface CmsVideoResponse { + list?: CmsVideoItem[]; + total?: number; + page?: number; + pagecount?: number; +} + +/** + * 获取指定视频源的分类视频列表 + */ +export async function GET(request: NextRequest) { + const authInfo = getAuthInfoFromCookie(request); + if (!authInfo || !authInfo.username) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const sourceKey = searchParams.get('source'); + const categoryId = searchParams.get('categoryId'); + const page = searchParams.get('page') || '1'; + + if (!sourceKey) { + return NextResponse.json( + { error: '缺少参数: source' }, + { status: 400 } + ); + } + + if (!categoryId) { + return NextResponse.json( + { error: '缺少参数: categoryId' }, + { status: 400 } + ); + } + + try { + const apiSites = await getAvailableApiSites(authInfo.username); + const targetSite = apiSites.find((site) => site.key === sourceKey); + + if (!targetSite) { + return NextResponse.json( + { error: `未找到指定的视频源: ${sourceKey}` }, + { status: 404 } + ); + } + + // 请求分类视频列表 + const videoUrl = `${targetSite.api}?ac=videolist&t=${categoryId}&pg=${page}`; + const videoResponse = await fetch(videoUrl, { + headers: API_CONFIG.search.headers, + signal: AbortSignal.timeout(10000), + }); + + if (!videoResponse.ok) { + throw new Error('获取视频列表失败'); + } + + const videoData: CmsVideoResponse = await videoResponse.json(); + + if (!videoData.list || !Array.isArray(videoData.list)) { + return NextResponse.json({ + results: [], + total: 0, + page: parseInt(page), + pageCount: 0, + }); + } + + // 转换为 SearchResult 格式 + const results: SearchResult[] = videoData.list.map((item) => { + const episodes: string[] = []; + const episodes_titles: string[] = []; + + // 解析播放信息 + if (item.vod_play_url && item.vod_play_from) { + const playUrls = item.vod_play_url.split('#'); + playUrls.forEach((episodeStr) => { + if (episodeStr.trim()) { + const [name, url] = episodeStr.split('$'); + if (name && url) { + episodes.push(url.trim()); + episodes_titles.push(name.trim()); + } + } + }); + } + + return { + id: item.vod_id.toString(), + title: item.vod_name, + poster: item.vod_pic || '', + year: item.vod_year || 'unknown', + episodes, + episodes_titles, + source: targetSite.key, + source_name: targetSite.name, + }; + }); + + return NextResponse.json({ + results, + total: videoData.total || 0, + page: parseInt(page), + pageCount: videoData.pagecount || 0, + }); + } catch (error) { + console.error('Failed to get videos:', error); + return NextResponse.json( + { error: '获取视频列表失败' }, + { status: 500 } + ); + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 0b57b60..746330f 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -155,6 +155,7 @@ export default async function RootLayout({ AI_ENABLE_HOMEPAGE_ENTRY: aiEnableHomepageEntry, AI_ENABLE_VIDEOCARD_ENTRY: aiEnableVideoCardEntry, AI_ENABLE_PLAYPAGE_ENTRY: aiEnablePlayPageEntry, + ENABLE_SOURCE_SEARCH: process.env.NEXT_PUBLIC_ENABLE_SOURCE_SEARCH !== 'false', }; return ( diff --git a/src/app/page.tsx b/src/app/page.tsx index 297e477..6f38ff6 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -2,7 +2,7 @@ 'use client'; -import { ChevronRight, Bot } from 'lucide-react'; +import { ChevronRight, Bot, ListVideo } from 'lucide-react'; import Link from 'next/link'; import { Suspense, useCallback, useEffect, useRef, useState } from 'react'; @@ -40,6 +40,7 @@ function HomeClient() { const [showHttpWarning, setShowHttpWarning] = useState(true); const [showAIChat, setShowAIChat] = useState(false); const [aiEnabled, setAiEnabled] = useState(false); + const [sourceSearchEnabled, setSourceSearchEnabled] = useState(true); // 检查AI功能是否启用 useEffect(() => { @@ -51,6 +52,14 @@ function HomeClient() { } }, []); + // 检查源站寻片功能是否启用 + useEffect(() => { + if (typeof window !== 'undefined') { + const enabled = (window as any).RUNTIME_CONFIG?.ENABLE_SOURCE_SEARCH !== false; + setSourceSearchEnabled(enabled); + } + }, []); + // 检查公告弹窗状态 useEffect(() => { if (typeof window !== 'undefined' && announcement) { @@ -154,9 +163,22 @@ function HomeClient() {
{/* 首页内容 */} <> - {/* AI问片入口 */} - {aiEnabled && ( -
+ {/* 源站寻片和AI问片入口 */} +
+ {/* 源站寻片入口 */} + {sourceSearchEnabled && ( + + + + )} + + {/* AI问片入口 */} + {aiEnabled && ( -
- )} + )} +
{/* 继续观看 */} diff --git a/src/app/source-search/page.tsx b/src/app/source-search/page.tsx new file mode 100644 index 0000000..1c51257 --- /dev/null +++ b/src/app/source-search/page.tsx @@ -0,0 +1,397 @@ +/* eslint-disable @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps */ +'use client'; + +import { Loader2, Search } from 'lucide-react'; +import { Suspense, useEffect, useRef, useState } from 'react'; + +import { ApiSite } from '@/lib/config'; +import { SearchResult } from '@/lib/types'; + +import CapsuleSwitch from '@/components/CapsuleSwitch'; +import PageLayout from '@/components/PageLayout'; +import VideoCard from '@/components/VideoCard'; + +interface Category { + id: string; + name: string; +} + +type ViewMode = 'browse' | 'search'; + +function SourceSearchPageClient() { + const [apiSites, setApiSites] = useState([]); + const [selectedSource, setSelectedSource] = useState(''); + const [categories, setCategories] = useState([]); + const [selectedCategory, setSelectedCategory] = useState(''); + const [videos, setVideos] = useState([]); + const [isLoadingSources, setIsLoadingSources] = useState(true); + const [isLoadingCategories, setIsLoadingCategories] = useState(false); + const [isLoadingVideos, setIsLoadingVideos] = useState(false); + const [currentPage, setCurrentPage] = useState(1); + const [hasMore, setHasMore] = useState(true); + const [viewMode, setViewMode] = useState('browse'); + const [searchKeyword, setSearchKeyword] = useState(''); + const [searchInputValue, setSearchInputValue] = useState(''); + const loadMoreRef = useRef(null); + + // 加载用户可用的视频源 + useEffect(() => { + const fetchApiSites = async () => { + setIsLoadingSources(true); + try { + const response = await fetch('/api/source-search/sources'); + const data = await response.json(); + if (data.sources && Array.isArray(data.sources)) { + setApiSites(data.sources); + // 默认选择第一个源 + if (data.sources.length > 0) { + setSelectedSource(data.sources[0].key); + } + } + } catch (error) { + console.error('Failed to load API sources:', error); + } finally { + setIsLoadingSources(false); + } + }; + + fetchApiSites(); + }, []); + + // 当选择的源变化时,加载分类列表 + useEffect(() => { + if (!selectedSource) return; + + const fetchCategories = async () => { + setIsLoadingCategories(true); + setCategories([]); + setSelectedCategory(''); + setVideos([]); + setCurrentPage(1); + setHasMore(true); + try { + const response = await fetch( + `/api/source-search/categories?source=${encodeURIComponent(selectedSource)}` + ); + const data = await response.json(); + if (data.categories && Array.isArray(data.categories)) { + setCategories(data.categories); + // 默认选择第一个分类 + if (data.categories.length > 0) { + setSelectedCategory(data.categories[0].id); + } + } + } catch (error) { + console.error('Failed to load categories:', error); + } finally { + setIsLoadingCategories(false); + } + }; + + fetchCategories(); + }, [selectedSource]); + + // 当选择的分类或页码变化时,加载视频列表(浏览模式) + useEffect(() => { + if (viewMode !== 'browse' || !selectedSource || !selectedCategory) return; + + const fetchVideos = async () => { + setIsLoadingVideos(true); + try { + const response = await fetch( + `/api/source-search/videos?source=${encodeURIComponent(selectedSource)}&categoryId=${encodeURIComponent(selectedCategory)}&page=${currentPage}` + ); + const data = await response.json(); + if (data.results && Array.isArray(data.results)) { + if (currentPage === 1) { + setVideos(data.results); + } else { + setVideos((prev) => [...prev, ...data.results]); + } + setHasMore(data.page < data.pageCount); + } + } catch (error) { + console.error('Failed to load videos:', error); + } finally { + setIsLoadingVideos(false); + } + }; + + fetchVideos(); + }, [selectedSource, selectedCategory, currentPage, viewMode]); + + // 当搜索关键词或页码变化时,执行搜索(搜索模式) + useEffect(() => { + if (viewMode !== 'search' || !selectedSource || !searchKeyword) return; + + const searchVideos = async () => { + setIsLoadingVideos(true); + try { + const response = await fetch( + `/api/source-search/search?source=${encodeURIComponent(selectedSource)}&keyword=${encodeURIComponent(searchKeyword)}&page=${currentPage}` + ); + const data = await response.json(); + if (data.results && Array.isArray(data.results)) { + if (currentPage === 1) { + setVideos(data.results); + } else { + setVideos((prev) => [...prev, ...data.results]); + } + setHasMore(data.page < data.pageCount); + } + } catch (error) { + console.error('Failed to search videos:', error); + } finally { + setIsLoadingVideos(false); + } + }; + + searchVideos(); + }, [selectedSource, searchKeyword, currentPage, viewMode]); + + // 当分类变化时,重置到第一页 + useEffect(() => { + setCurrentPage(1); + setVideos([]); + setHasMore(true); + }, [selectedCategory]); + + // 处理搜索提交 + const handleSearch = (e: React.FormEvent) => { + e.preventDefault(); + if (searchInputValue.trim()) { + setSearchKeyword(searchInputValue.trim()); + setViewMode('search'); + setCurrentPage(1); + setVideos([]); + setHasMore(true); + } + }; + + // 切换回浏览模式 + const handleBackToBrowse = () => { + setViewMode('browse'); + setSearchKeyword(''); + setSearchInputValue(''); + setCurrentPage(1); + setVideos([]); + setHasMore(true); + }; + + // Intersection Observer for infinite scroll + useEffect(() => { + if (!loadMoreRef.current) return; + + const observer = new IntersectionObserver( + (entries) => { + const target = entries[0]; + if (target.isIntersecting && hasMore && !isLoadingVideos) { + setCurrentPage((prev) => prev + 1); + } + }, + { threshold: 0.1 } + ); + + observer.observe(loadMoreRef.current); + + return () => { + observer.disconnect(); + }; + }, [hasMore, isLoadingVideos]); + + return ( + +
+ {/* 页面标题 */} +
+

+ 源站寻片 +

+

+ 根据可用视频源浏览分类内容 +

+
+ + {/* 源选择和分类选择 */} +
+ {/* 源选择 CapsuleSwitch */} +
+ + {isLoadingSources ? ( +
+ + + 加载视频源中... + +
+ ) : apiSites.length === 0 ? ( +
+ + 暂无可用源 + +
+ ) : ( +
+ ({ + label: site.name, + value: site.key, + }))} + active={selectedSource} + onChange={(value) => { + setSelectedSource(value); + handleBackToBrowse(); + }} + /> +
+ )} +
+ + {/* 搜索框 */} + {selectedSource && ( +
+
+
+ setSearchInputValue(e.target.value)} + placeholder='搜索视频...' + className='w-full h-12 rounded-lg bg-gray-50/80 py-3 pl-4 pr-12 text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:bg-white border border-gray-200/50 shadow-sm dark:bg-gray-800 dark:text-gray-300 dark:focus:bg-gray-700 dark:border-gray-700' + /> + +
+
+
+ )} + + {/* 搜索结果提示和返回按钮 */} + {viewMode === 'search' && searchKeyword && ( +
+ + 搜索结果: {searchKeyword} + + +
+ )} + + {/* 分类选择 CapsuleSwitch */} + {selectedSource && viewMode === 'browse' && ( +
+ + {isLoadingCategories ? ( +
+ + + 加载分类中... + +
+ ) : categories.length === 0 ? ( +
+ + 暂无分类 + +
+ ) : ( +
+ ({ + label: category.name, + value: category.id, + }))} + active={selectedCategory} + onChange={setSelectedCategory} + /> +
+ )} +
+ )} +
+ + {/* 视频列表 */} + {selectedSource && (viewMode === 'search' ? searchKeyword : selectedCategory) && ( +
+
+

+ 视频列表 +

+
+ + {isLoadingVideos && currentPage === 1 ? ( +
+
+
+ ) : videos.length === 0 ? ( +
+ 暂无视频 +
+ ) : ( + <> +
+ {videos.map((item) => ( +
+ 1 ? 'tv' : 'movie'} + cmsData={{ + desc: item.desc, + episodes: item.episodes, + episodes_titles: item.episodes_titles, + }} + /> +
+ ))} +
+ + {/* Infinite scroll trigger */} +
+ {isLoadingVideos && ( +
+ )} + {!hasMore && videos.length > 0 && ( + + 没有更多了 + + )} +
+ + )} +
+ )} +
+
+ ); +} + +export default function SourceSearchPage() { + return ( + + + + ); +} diff --git a/src/components/CapsuleSwitch.tsx b/src/components/CapsuleSwitch.tsx index 1e3396a..15e118f 100644 --- a/src/components/CapsuleSwitch.tsx +++ b/src/components/CapsuleSwitch.tsx @@ -16,7 +16,14 @@ const CapsuleSwitch: React.FC = ({ className, }) => { const containerRef = useRef(null); + const scrollContainerRef = useRef(null); const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]); + const isScrollingRef = useRef(false); + const scrollTimeoutRef = useRef(null); + const isDraggingRef = useRef(false); + const startXRef = useRef(0); + const scrollLeftRef = useRef(0); + const hasDraggedRef = useRef(false); const [indicatorStyle, setIndicatorStyle] = useState<{ left: number; width: number; @@ -24,79 +31,205 @@ const CapsuleSwitch: React.FC = ({ const activeIndex = options.findIndex((opt) => opt.value === active); - // 更新指示器位置 - const updateIndicatorPosition = () => { + // 更新指示器位置(仅更新位置,不触发滚动) + const updateIndicatorPosition = (autoScroll = false) => { if ( activeIndex >= 0 && buttonRefs.current[activeIndex] && - containerRef.current + containerRef.current && + scrollContainerRef.current ) { const button = buttonRefs.current[activeIndex]; - const container = containerRef.current; - if (button && container) { - const buttonRect = button.getBoundingClientRect(); - const containerRect = container.getBoundingClientRect(); + const scrollContainer = scrollContainerRef.current; - if (buttonRect.width > 0) { - setIndicatorStyle({ - left: buttonRect.left - containerRect.left, - width: buttonRect.width, - }); + if (button) { + const buttonOffsetLeft = button.offsetLeft; + const buttonWidth = button.offsetWidth; + + setIndicatorStyle({ + left: buttonOffsetLeft, + width: buttonWidth, + }); + + // 只在需要自动滚动时才执行 + if (autoScroll && !isScrollingRef.current) { + const buttonRect = button.getBoundingClientRect(); + const scrollContainerRect = scrollContainer.getBoundingClientRect(); + const isVisible = + buttonRect.left >= scrollContainerRect.left && + buttonRect.right <= scrollContainerRect.right; + + if (!isVisible) { + // 将选中项滚动到视图中心 + const scrollToPosition = + buttonOffsetLeft - + scrollContainer.offsetWidth / 2 + + buttonWidth / 2; + scrollContainer.scrollTo({ + left: scrollToPosition, + behavior: 'smooth', + }); + } } } } }; - // 组件挂载时立即计算初始位置 + // 组件挂载时立即计算初始位置并滚动到选中项 useEffect(() => { - const timeoutId = setTimeout(updateIndicatorPosition, 0); + const timeoutId = setTimeout(() => updateIndicatorPosition(true), 0); return () => clearTimeout(timeoutId); }, []); - // 监听选中项变化 + // 监听选中项变化,自动滚动到新选中项 useEffect(() => { - const timeoutId = setTimeout(updateIndicatorPosition, 0); + const timeoutId = setTimeout(() => updateIndicatorPosition(true), 0); return () => clearTimeout(timeoutId); }, [activeIndex]); + // 监听滚动事件,仅更新指示器位置 + useEffect(() => { + const scrollContainer = scrollContainerRef.current; + if (!scrollContainer) return; + + const handleScroll = () => { + // 标记正在滚动 + isScrollingRef.current = true; + + // 清除之前的超时 + if (scrollTimeoutRef.current) { + clearTimeout(scrollTimeoutRef.current); + } + + // 仅更新指示器位置,不触发自动滚动 + updateIndicatorPosition(false); + + // 滚动结束后重置标记 + scrollTimeoutRef.current = setTimeout(() => { + isScrollingRef.current = false; + }, 150); + }; + + scrollContainer.addEventListener('scroll', handleScroll, { passive: true }); + return () => { + scrollContainer.removeEventListener('scroll', handleScroll); + if (scrollTimeoutRef.current) { + clearTimeout(scrollTimeoutRef.current); + } + }; + }, [activeIndex]); + + // 鼠标拖动功能 + useEffect(() => { + const scrollContainer = scrollContainerRef.current; + if (!scrollContainer) return; + + const handleMouseDown = (e: MouseEvent) => { + isDraggingRef.current = true; + hasDraggedRef.current = false; + startXRef.current = e.pageX - scrollContainer.offsetLeft; + scrollLeftRef.current = scrollContainer.scrollLeft; + scrollContainer.style.cursor = 'grabbing'; + scrollContainer.style.userSelect = 'none'; + }; + + const handleMouseLeave = () => { + isDraggingRef.current = false; + scrollContainer.style.cursor = 'grab'; + }; + + const handleMouseUp = () => { + isDraggingRef.current = false; + scrollContainer.style.cursor = 'grab'; + // 短暂延迟后重置拖动标记,防止点击事件被触发 + setTimeout(() => { + hasDraggedRef.current = false; + }, 50); + }; + + const handleMouseMove = (e: MouseEvent) => { + if (!isDraggingRef.current) return; + e.preventDefault(); + const x = e.pageX - scrollContainer.offsetLeft; + const walk = (x - startXRef.current) * 1.5; // 调整拖动速度 + + // 如果移动距离超过5px,标记为已拖动 + if (Math.abs(walk) > 5) { + hasDraggedRef.current = true; + } + + scrollContainer.scrollLeft = scrollLeftRef.current - walk; + }; + + scrollContainer.style.cursor = 'grab'; + scrollContainer.addEventListener('mousedown', handleMouseDown); + scrollContainer.addEventListener('mouseleave', handleMouseLeave); + scrollContainer.addEventListener('mouseup', handleMouseUp); + scrollContainer.addEventListener('mousemove', handleMouseMove); + + return () => { + scrollContainer.removeEventListener('mousedown', handleMouseDown); + scrollContainer.removeEventListener('mouseleave', handleMouseLeave); + scrollContainer.removeEventListener('mouseup', handleMouseUp); + scrollContainer.removeEventListener('mousemove', handleMouseMove); + }; + }, []); + return (
- {/* 滑动的白色背景指示器 */} - {indicatorStyle.width > 0 && ( -
- )} - - {options.map((opt, index) => { - const isActive = active === opt.value; - return ( - - ); - })} + /> + )} + + {options.map((opt, index) => { + const isActive = active === opt.value; + return ( + + ); + })} +
); }; diff --git a/src/components/VideoCard.tsx b/src/components/VideoCard.tsx index 630e3fd..f1720ef 100644 --- a/src/components/VideoCard.tsx +++ b/src/components/VideoCard.tsx @@ -40,7 +40,7 @@ export interface VideoCardProps { source_names?: string[]; progress?: number; year?: string; - from: 'playrecord' | 'favorite' | 'search' | 'douban' | 'tmdb'; + from: 'playrecord' | 'favorite' | 'search' | 'douban' | 'tmdb' | 'source-search'; currentEpisode?: number; douban_id?: number; tmdb_id?: number; @@ -441,6 +441,16 @@ const VideoCard = forwardRef(function VideoCard showRating: !!rate, showYear: false, }, + 'source-search': { + showSourceName: false, + showProgress: false, + showPlayButton: true, + showHeart: true, + showCheckCircle: false, + showDoubanLink: true, + showRating: !!rate, + showYear: true, + }, }; return configs[from] || configs.search; }, [from, isAggregate, douban_id, rate, isUpcoming]);