From f8eb7cea4c19514638bc8077349792958fb46057 Mon Sep 17 00:00:00 2001 From: leowang Date: Sun, 24 May 2026 16:23:19 +0800 Subject: [PATCH] Tune home page layout --- src/app/globals.css | 5 +- src/app/page.tsx | 281 ++--- src/components/CapsuleSwitch.tsx | 10 +- src/components/ScrollableRow.tsx | 2 +- src/components/VideoCard.tsx | 2004 ++++++++++++++++-------------- 5 files changed, 1244 insertions(+), 1058 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index 0d23354..11224f4 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -199,10 +199,7 @@ html { body { margin: 0; - background: - radial-gradient(circle at 18% -12%, rgb(var(--color-accent) / 0.1), transparent 34rem), - linear-gradient(180deg, rgb(var(--color-background)) 0%, rgb(var(--color-surface-secondary)) 100%); - background-attachment: fixed; + background: rgb(var(--color-background)); color: rgb(var(--color-foreground)); font-family: var(--font-body); font-feature-settings: "tnum" 1, "cv02" 1, "cv03" 1, "cv04" 1; diff --git a/src/app/page.tsx b/src/app/page.tsx index f82a5e3..b4d402e 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -2,7 +2,13 @@ 'use client'; -import { Button, Card, EmptyState, Link as HeroLink, Skeleton } from '@heroui/react'; +import { + Button, + Card, + EmptyState, + Link as HeroLink, + Skeleton, +} from '@heroui/react'; import { Suspense, useEffect, useState } from 'react'; import { @@ -175,9 +181,10 @@ function HomeClient() { setActiveTab(value as 'home' | 'favorites')} /> @@ -203,7 +210,7 @@ function HomeClient() { )} -
+
{favoriteItems.map((item) => (
精选推荐 热门电影
- - 查看更多 - + 查看更多 {loading ? // 加载状态显示灰色占位数据 - Array.from({ length: 8 }).map((_, index) => ( -
- - -
- )) + Array.from({ length: 8 }).map((_, index) => ( +
+ + +
+ )) : // 显示真实数据 - hotMovies.map((movie, index) => ( -
- -
- ))} + hotMovies.map((movie, index) => ( +
+ +
+ ))}
@@ -279,38 +282,36 @@ function HomeClient() { Series 热门剧集
- - 查看更多 - + 查看更多 {loading ? // 加载状态显示灰色占位数据 - Array.from({ length: 8 }).map((_, index) => ( -
- - -
- )) + Array.from({ length: 8 }).map((_, index) => ( +
+ + +
+ )) : // 显示真实数据 - hotTvShows.map((show, index) => ( -
- -
- ))} + hotTvShows.map((show, index) => ( +
+ +
+ ))}
@@ -321,69 +322,65 @@ function HomeClient() { Bangumi 新番放送
- - 查看更多 - + 查看更多 {loading ? // 加载状态显示灰色占位数据 - Array.from({ length: 8 }).map((_, index) => ( -
- - -
- )) - : // 展示当前日期的番剧 - (() => { - // 获取当前日期对应的星期 - const today = new Date(); - const weekdays = [ - 'Sun', - 'Mon', - 'Tue', - 'Wed', - 'Thu', - 'Fri', - 'Sat', - ]; - const currentWeekday = weekdays[today.getDay()]; - - // 找到当前星期对应的番剧数据 - const todayAnimes = - bangumiCalendarData.find( - (item) => item.weekday.en === currentWeekday - )?.items || []; - - return todayAnimes.map((anime, index) => ( + Array.from({ length: 8 }).map((_, index) => (
- + +
- )); - })()} + )) + : // 展示当前日期的番剧 + (() => { + // 获取当前日期对应的星期 + const today = new Date(); + const weekdays = [ + 'Sun', + 'Mon', + 'Tue', + 'Wed', + 'Thu', + 'Fri', + 'Sat', + ]; + const currentWeekday = weekdays[today.getDay()]; + + // 找到当前星期对应的番剧数据 + const todayAnimes = + bangumiCalendarData.find( + (item) => item.weekday.en === currentWeekday + )?.items || []; + + return todayAnimes.map((anime, index) => ( +
+ +
+ )); + })()}
@@ -394,40 +391,36 @@ function HomeClient() { Shows 热门综艺 - - 查看更多 - + 查看更多 {loading ? // 加载状态显示灰色占位数据 - Array.from({ length: 8 }).map((_, index) => ( -
- - -
- )) + Array.from({ length: 8 }).map((_, index) => ( +
+ + +
+ )) : // 显示真实数据 - hotVarietyShows.map((show, index) => ( -
- -
- ))} + hotVarietyShows.map((show, index) => ( +
+ +
+ ))}
diff --git a/src/components/CapsuleSwitch.tsx b/src/components/CapsuleSwitch.tsx index 64c0a66..48dea12 100644 --- a/src/components/CapsuleSwitch.tsx +++ b/src/components/CapsuleSwitch.tsx @@ -7,6 +7,7 @@ interface CapsuleSwitchProps { active: string; onChange: (value: string) => void; className?: string; + compact?: boolean; } const CapsuleSwitch: React.FC = ({ @@ -14,13 +15,20 @@ const CapsuleSwitch: React.FC = ({ active, onChange, className, + compact = false, }) => { + const compactClasses = + 'mx-auto w-fit [&_.tabs__list]:w-fit [&_.tabs__tab]:h-9 [&_.tabs__tab]:w-auto [&_.tabs__tab]:min-w-16 [&_.tabs__tab]:px-4 [&_.tabs__tab]:text-sm'; + return ( ({ key: opt.value, label: opt.label }))} selectedKey={active} + variant={compact ? 'primary' : 'secondary'} onSelectionChange={onChange} /> ); diff --git a/src/components/ScrollableRow.tsx b/src/components/ScrollableRow.tsx index bf81da4..cc2b100 100644 --- a/src/components/ScrollableRow.tsx +++ b/src/components/ScrollableRow.tsx @@ -103,7 +103,7 @@ export default function ScrollableRow({ >
{children} diff --git a/src/components/VideoCard.tsx b/src/components/VideoCard.tsx index db10528..352f845 100644 --- a/src/components/VideoCard.tsx +++ b/src/components/VideoCard.tsx @@ -1,7 +1,22 @@ /* eslint-disable @typescript-eslint/no-explicit-any,react-hooks/exhaustive-deps,@typescript-eslint/no-empty-function */ -import { ExternalLink, Heart, Link as LinkIcon, PlayCircleIcon, Radio, Trash2 } from 'lucide-react'; -import { Badge, Button, Card, Chip, Link as HeroLink, ProgressBar, Tooltip } from '@heroui/react'; +import { + ExternalLink, + Heart, + Link as LinkIcon, + PlayCircleIcon, + Radio, + Trash2, +} from 'lucide-react'; +import { + Badge, + Button, + Card, + Chip, + Link as HeroLink, + ProgressBar, + Tooltip, +} from '@heroui/react'; import Image from 'next/image'; import { useRouter } from 'next/navigation'; import React, { @@ -59,984 +74,1157 @@ export type VideoCardHandle = { setDoubanId: (id?: number) => void; }; -const VideoCard = forwardRef(function VideoCard( - { - id, - title = '', - query = '', - poster = '', - episodes, - source, - source_name, - source_names, - progress = 0, - year, - from, - currentEpisode, - douban_id, - onDelete, - rate, - type = '', - isBangumi = false, - isAggregate = false, - origin = 'vod', - vod_class, - vod_tag, - }: VideoCardProps, - ref -) { - const router = useRouter(); - const [favorited, setFavorited] = useState(false); - const [isLoading, setIsLoading] = useState(false); - const [showMobileActions, setShowMobileActions] = useState(false); - const [searchFavorited, setSearchFavorited] = useState(null); // 搜索结果的收藏状态 +const VideoCard = forwardRef( + function VideoCard( + { + id, + title = '', + query = '', + poster = '', + episodes, + source, + source_name, + source_names, + progress = 0, + year, + from, + currentEpisode, + douban_id, + onDelete, + rate, + type = '', + isBangumi = false, + isAggregate = false, + origin = 'vod', + vod_class, + vod_tag, + }: VideoCardProps, + ref + ) { + const router = useRouter(); + const [favorited, setFavorited] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [showMobileActions, setShowMobileActions] = useState(false); + const [searchFavorited, setSearchFavorited] = useState( + null + ); // 搜索结果的收藏状态 - // 可外部修改的可控字段 - const [dynamicEpisodes, setDynamicEpisodes] = useState( - episodes - ); - const [dynamicSourceNames, setDynamicSourceNames] = useState( - source_names - ); - const [dynamicDoubanId, setDynamicDoubanId] = useState( - douban_id - ); - - useEffect(() => { - setDynamicEpisodes(episodes); - }, [episodes]); - - useEffect(() => { - setDynamicSourceNames(source_names); - }, [source_names]); - - useEffect(() => { - setDynamicDoubanId(douban_id); - }, [douban_id]); - - useImperativeHandle(ref, () => ({ - setEpisodes: (eps?: number) => setDynamicEpisodes(eps), - setSourceNames: (names?: string[]) => setDynamicSourceNames(names), - setDoubanId: (id?: number) => setDynamicDoubanId(id), - })); - - const actualTitle = title; - const actualPoster = poster; - const actualSource = source; - const actualId = id; - const actualDoubanId = dynamicDoubanId; - const actualEpisodes = dynamicEpisodes; - const actualYear = year; - const actualQuery = query || ''; - const actualSearchType = isAggregate - ? (actualEpisodes && actualEpisodes === 1 ? 'movie' : 'tv') - : type; - - // 获取收藏状态(搜索结果、豆瓣和短剧页面不检查) - useEffect(() => { - if (from === 'douban' || from === 'search' || from === 'shortdrama' || !actualSource || !actualId) return; - - const fetchFavoriteStatus = async () => { - try { - const fav = await isFavorited(actualSource, actualId); - setFavorited(fav); - } catch (err) { - throw new Error('检查收藏状态失败'); - } - }; - - fetchFavoriteStatus(); - - // 监听收藏状态更新事件 - const storageKey = generateStorageKey(actualSource, actualId); - const unsubscribe = subscribeToDataUpdates( - 'favoritesUpdated', - (newFavorites: Record) => { - // 检查当前项目是否在新的收藏列表中 - const isNowFavorited = !!newFavorites[storageKey]; - setFavorited(isNowFavorited); - } + // 可外部修改的可控字段 + const [dynamicEpisodes, setDynamicEpisodes] = useState( + episodes + ); + const [dynamicSourceNames, setDynamicSourceNames] = useState< + string[] | undefined + >(source_names); + const [dynamicDoubanId, setDynamicDoubanId] = useState( + douban_id ); - return unsubscribe; - }, [from, actualSource, actualId]); + useEffect(() => { + setDynamicEpisodes(episodes); + }, [episodes]); - const handleToggleFavorite = useCallback( - async (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - if (from === 'douban' || from === 'shortdrama' || !actualSource || !actualId) return; + useEffect(() => { + setDynamicSourceNames(source_names); + }, [source_names]); - try { - // 确定当前收藏状态 - const currentFavorited = from === 'search' ? searchFavorited : favorited; + useEffect(() => { + setDynamicDoubanId(douban_id); + }, [douban_id]); - if (currentFavorited) { - // 如果已收藏,删除收藏 - await deleteFavorite(actualSource, actualId); - if (from === 'search') { - setSearchFavorited(false); - } else { - setFavorited(false); - } - } else { - // 如果未收藏,添加收藏 - await saveFavorite(actualSource, actualId, { - title: actualTitle, - source_name: source_name || '', - year: actualYear || '', - cover: actualPoster, - total_episodes: actualEpisodes ?? 1, - save_time: Date.now(), - }); - if (from === 'search') { - setSearchFavorited(true); - } else { - setFavorited(true); - } + useImperativeHandle(ref, () => ({ + setEpisodes: (eps?: number) => setDynamicEpisodes(eps), + setSourceNames: (names?: string[]) => setDynamicSourceNames(names), + setDoubanId: (id?: number) => setDynamicDoubanId(id), + })); + + const actualTitle = title; + const actualPoster = poster; + const actualSource = source; + const actualId = id; + const actualDoubanId = dynamicDoubanId; + const actualEpisodes = dynamicEpisodes; + const actualYear = year; + const actualQuery = query || ''; + const actualSearchType = isAggregate + ? actualEpisodes && actualEpisodes === 1 + ? 'movie' + : 'tv' + : type; + + // 获取收藏状态(搜索结果、豆瓣和短剧页面不检查) + useEffect(() => { + if ( + from === 'douban' || + from === 'search' || + from === 'shortdrama' || + !actualSource || + !actualId + ) + return; + + const fetchFavoriteStatus = async () => { + try { + const fav = await isFavorited(actualSource, actualId); + setFavorited(fav); + } catch (err) { + throw new Error('检查收藏状态失败'); } - } catch (err) { - throw new Error('切换收藏状态失败'); + }; + + fetchFavoriteStatus(); + + // 监听收藏状态更新事件 + const storageKey = generateStorageKey(actualSource, actualId); + const unsubscribe = subscribeToDataUpdates( + 'favoritesUpdated', + (newFavorites: Record) => { + // 检查当前项目是否在新的收藏列表中 + const isNowFavorited = !!newFavorites[storageKey]; + setFavorited(isNowFavorited); + } + ); + + return unsubscribe; + }, [from, actualSource, actualId]); + + const handleToggleFavorite = useCallback( + async (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + if ( + from === 'douban' || + from === 'shortdrama' || + !actualSource || + !actualId + ) + return; + + try { + // 确定当前收藏状态 + const currentFavorited = + from === 'search' ? searchFavorited : favorited; + + if (currentFavorited) { + // 如果已收藏,删除收藏 + await deleteFavorite(actualSource, actualId); + if (from === 'search') { + setSearchFavorited(false); + } else { + setFavorited(false); + } + } else { + // 如果未收藏,添加收藏 + await saveFavorite(actualSource, actualId, { + title: actualTitle, + source_name: source_name || '', + year: actualYear || '', + cover: actualPoster, + total_episodes: actualEpisodes ?? 1, + save_time: Date.now(), + }); + if (from === 'search') { + setSearchFavorited(true); + } else { + setFavorited(true); + } + } + } catch (err) { + throw new Error('切换收藏状态失败'); + } + }, + [ + from, + actualSource, + actualId, + actualTitle, + source_name, + actualYear, + actualPoster, + actualEpisodes, + favorited, + searchFavorited, + ] + ); + + const handleDeleteRecord = useCallback( + async (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (from !== 'playrecord' || !actualSource || !actualId) return; + try { + await deletePlayRecord(actualSource, actualId); + onDelete?.(); + } catch (err) { + throw new Error('删除播放记录失败'); + } + }, + [from, actualSource, actualId, onDelete] + ); + + const handleClick = useCallback(() => { + // 如果从搜索页面点击,设置标记以便返回时使用缓存 + if (from === 'search' && typeof window !== 'undefined') { + sessionStorage.setItem('fromPlayPage', 'true'); } - }, - [ + + if (origin === 'live' && actualSource && actualId) { + // 直播内容跳转到直播页面 + const url = `/live?source=${actualSource.replace( + 'live_', + '' + )}&id=${actualId.replace('live_', '')}`; + router.push(url); + } else if (from === 'shortdrama' && actualId) { + // 短剧内容跳转到播放页面,传递剧集ID用于调用获取全集地址的接口 + const urlParams = new URLSearchParams(); + urlParams.set('shortdrama_id', actualId); + urlParams.set('title', actualTitle.trim()); + if (actualYear) urlParams.set('year', actualYear); + if (vod_class) urlParams.set('vod_class', vod_class); + if (vod_tag) urlParams.set('vod_tag', vod_tag); + + const url = `/play?${urlParams.toString()}`; + router.push(url); + } else if ( + from === 'douban' || + (isAggregate && !actualSource && !actualId) + ) { + const url = `/play?title=${encodeURIComponent(actualTitle.trim())}${ + actualYear ? `&year=${actualYear}` : '' + }${actualSearchType ? `&stype=${actualSearchType}` : ''}${ + isAggregate ? '&prefer=true' : '' + }${ + actualQuery ? `&stitle=${encodeURIComponent(actualQuery.trim())}` : '' + }`; + router.push(url); + } else if (actualSource && actualId) { + const url = `/play?source=${actualSource}&id=${actualId}&title=${encodeURIComponent( + actualTitle + )}${actualYear ? `&year=${actualYear}` : ''}${ + isAggregate ? '&prefer=true' : '' + }${ + actualQuery ? `&stitle=${encodeURIComponent(actualQuery.trim())}` : '' + }${actualSearchType ? `&stype=${actualSearchType}` : ''}`; + router.push(url); + } + }, [ + origin, + from, + actualSource, + actualId, + router, + actualTitle, + actualYear, + isAggregate, + actualQuery, + actualSearchType, + ]); + + // 新标签页播放处理函数 + const handlePlayInNewTab = useCallback(() => { + // 如果从搜索页面点击,设置标记以便返回时使用缓存 + if (from === 'search' && typeof window !== 'undefined') { + sessionStorage.setItem('fromPlayPage', 'true'); + } + + if (origin === 'live' && actualSource && actualId) { + // 直播内容跳转到直播页面 + const url = `/live?source=${actualSource.replace( + 'live_', + '' + )}&id=${actualId.replace('live_', '')}`; + window.open(url, '_blank'); + } else if ( + from === 'douban' || + (isAggregate && !actualSource && !actualId) + ) { + const url = `/play?title=${encodeURIComponent(actualTitle.trim())}${ + actualYear ? `&year=${actualYear}` : '' + }${actualSearchType ? `&stype=${actualSearchType}` : ''}${ + isAggregate ? '&prefer=true' : '' + }${ + actualQuery ? `&stitle=${encodeURIComponent(actualQuery.trim())}` : '' + }`; + window.open(url, '_blank'); + } else if (actualSource && actualId) { + const url = `/play?source=${actualSource}&id=${actualId}&title=${encodeURIComponent( + actualTitle + )}${actualYear ? `&year=${actualYear}` : ''}${ + isAggregate ? '&prefer=true' : '' + }${ + actualQuery ? `&stitle=${encodeURIComponent(actualQuery.trim())}` : '' + }${actualSearchType ? `&stype=${actualSearchType}` : ''}`; + window.open(url, '_blank'); + } + }, [ + origin, from, actualSource, actualId, actualTitle, - source_name, actualYear, - actualPoster, - actualEpisodes, - favorited, + isAggregate, + actualQuery, + actualSearchType, + ]); + + // 检查搜索结果的收藏状态 + const checkSearchFavoriteStatus = useCallback(async () => { + if ( + from === 'search' && + !isAggregate && + actualSource && + actualId && + searchFavorited === null + ) { + try { + const fav = await isFavorited(actualSource, actualId); + setSearchFavorited(fav); + } catch (err) { + setSearchFavorited(false); + } + } + }, [from, isAggregate, actualSource, actualId, searchFavorited]); + + // 长按操作 + const handleLongPress = useCallback(() => { + if (!showMobileActions) { + // 防止重复触发 + // 立即显示菜单,避免等待数据加载导致动画卡顿 + setShowMobileActions(true); + + // 异步检查收藏状态,不阻塞菜单显示 + if ( + from === 'search' && + !isAggregate && + actualSource && + actualId && + searchFavorited === null + ) { + checkSearchFavoriteStatus(); + } + } + }, [ + showMobileActions, + from, + isAggregate, + actualSource, + actualId, searchFavorited, - ] - ); + checkSearchFavoriteStatus, + ]); - const handleDeleteRecord = useCallback( - async (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - if (from !== 'playrecord' || !actualSource || !actualId) return; - try { - await deletePlayRecord(actualSource, actualId); - onDelete?.(); - } catch (err) { - throw new Error('删除播放记录失败'); + // 长按手势hook + const longPressProps = useLongPress({ + onLongPress: handleLongPress, + onClick: handleClick, // 保持点击播放功能 + longPressDelay: 500, + }); + + const config = useMemo(() => { + const configs = { + playrecord: { + showSourceName: true, + showProgress: true, + showPlayButton: true, + showHeart: true, + showCheckCircle: true, + showDoubanLink: false, + showRating: false, + showYear: false, + }, + favorite: { + showSourceName: true, + showProgress: false, + showPlayButton: true, + showHeart: true, + showCheckCircle: false, + showDoubanLink: false, + showRating: false, + showYear: false, + }, + search: { + showSourceName: true, + showProgress: false, + showPlayButton: true, + showHeart: true, // 移动端菜单中需要显示收藏选项 + showCheckCircle: false, + showDoubanLink: true, // 移动端菜单中显示豆瓣链接 + showRating: false, + showYear: true, + }, + douban: { + showSourceName: false, + showProgress: false, + showPlayButton: true, + showHeart: false, + showCheckCircle: false, + showDoubanLink: true, + showRating: !!rate, + showYear: false, + }, + shortdrama: { + showSourceName: true, + showProgress: false, + showPlayButton: true, + showHeart: false, // 短剧不显示收藏功能 + showCheckCircle: false, + showDoubanLink: false, + showRating: !!rate, + showYear: true, + }, + }; + return configs[from] || configs.search; + }, [from, isAggregate, douban_id, rate]); + + // 移动端操作菜单配置 + const mobileActions = useMemo(() => { + const actions = []; + + // 播放操作 + if (config.showPlayButton) { + actions.push({ + id: 'play', + label: origin === 'live' ? '观看直播' : '播放', + icon: , + onClick: handleClick, + color: 'primary' as const, + }); + + // 新标签页播放 + actions.push({ + id: 'play-new-tab', + label: origin === 'live' ? '新标签页观看' : '新标签页播放', + icon: , + onClick: handlePlayInNewTab, + color: 'default' as const, + }); } - }, - [from, actualSource, actualId, onDelete] - ); - const handleClick = useCallback(() => { - // 如果从搜索页面点击,设置标记以便返回时使用缓存 - if (from === 'search' && typeof window !== 'undefined') { - sessionStorage.setItem('fromPlayPage', 'true'); - } + // 聚合源信息 - 直接在菜单中展示,不需要单独的操作项 - if (origin === 'live' && actualSource && actualId) { - // 直播内容跳转到直播页面 - const url = `/live?source=${actualSource.replace('live_', '')}&id=${actualId.replace('live_', '')}`; - router.push(url); - } else if (from === 'shortdrama' && actualId) { - // 短剧内容跳转到播放页面,传递剧集ID用于调用获取全集地址的接口 - const urlParams = new URLSearchParams(); - urlParams.set('shortdrama_id', actualId); - urlParams.set('title', actualTitle.trim()); - if (actualYear) urlParams.set('year', actualYear); - if (vod_class) urlParams.set('vod_class', vod_class); - if (vod_tag) urlParams.set('vod_tag', vod_tag); + // 收藏/取消收藏操作 + if ( + config.showHeart && + from !== 'douban' && + from !== 'shortdrama' && + actualSource && + actualId + ) { + const currentFavorited = + from === 'search' ? searchFavorited : favorited; - const url = `/play?${urlParams.toString()}`; - router.push(url); - } else if (from === 'douban' || (isAggregate && !actualSource && !actualId)) { - const url = `/play?title=${encodeURIComponent(actualTitle.trim())}${actualYear ? `&year=${actualYear}` : '' - }${actualSearchType ? `&stype=${actualSearchType}` : ''}${isAggregate ? '&prefer=true' : ''}${actualQuery ? `&stitle=${encodeURIComponent(actualQuery.trim())}` : ''}`; - router.push(url); - } else if (actualSource && actualId) { - const url = `/play?source=${actualSource}&id=${actualId}&title=${encodeURIComponent( - actualTitle - )}${actualYear ? `&year=${actualYear}` : ''}${isAggregate ? '&prefer=true' : '' - }${actualQuery ? `&stitle=${encodeURIComponent(actualQuery.trim())}` : '' - }${actualSearchType ? `&stype=${actualSearchType}` : ''}`; - router.push(url); - } - }, [ - origin, - from, - actualSource, - actualId, - router, - actualTitle, - actualYear, - isAggregate, - actualQuery, - actualSearchType, - ]); - - // 新标签页播放处理函数 - const handlePlayInNewTab = useCallback(() => { - // 如果从搜索页面点击,设置标记以便返回时使用缓存 - if (from === 'search' && typeof window !== 'undefined') { - sessionStorage.setItem('fromPlayPage', 'true'); - } - - if (origin === 'live' && actualSource && actualId) { - // 直播内容跳转到直播页面 - const url = `/live?source=${actualSource.replace('live_', '')}&id=${actualId.replace('live_', '')}`; - window.open(url, '_blank'); - } else if (from === 'douban' || (isAggregate && !actualSource && !actualId)) { - const url = `/play?title=${encodeURIComponent(actualTitle.trim())}${actualYear ? `&year=${actualYear}` : ''}${actualSearchType ? `&stype=${actualSearchType}` : ''}${isAggregate ? '&prefer=true' : ''}${actualQuery ? `&stitle=${encodeURIComponent(actualQuery.trim())}` : ''}`; - window.open(url, '_blank'); - } else if (actualSource && actualId) { - const url = `/play?source=${actualSource}&id=${actualId}&title=${encodeURIComponent( - actualTitle - )}${actualYear ? `&year=${actualYear}` : ''}${isAggregate ? '&prefer=true' : '' - }${actualQuery ? `&stitle=${encodeURIComponent(actualQuery.trim())}` : '' - }${actualSearchType ? `&stype=${actualSearchType}` : ''}`; - window.open(url, '_blank'); - } - }, [ - origin, - from, - actualSource, - actualId, - actualTitle, - actualYear, - isAggregate, - actualQuery, - actualSearchType, - ]); - - // 检查搜索结果的收藏状态 - const checkSearchFavoriteStatus = useCallback(async () => { - if (from === 'search' && !isAggregate && actualSource && actualId && searchFavorited === null) { - try { - const fav = await isFavorited(actualSource, actualId); - setSearchFavorited(fav); - } catch (err) { - setSearchFavorited(false); - } - } - }, [from, isAggregate, actualSource, actualId, searchFavorited]); - - // 长按操作 - const handleLongPress = useCallback(() => { - if (!showMobileActions) { // 防止重复触发 - // 立即显示菜单,避免等待数据加载导致动画卡顿 - setShowMobileActions(true); - - // 异步检查收藏状态,不阻塞菜单显示 - if (from === 'search' && !isAggregate && actualSource && actualId && searchFavorited === null) { - checkSearchFavoriteStatus(); - } - } - }, [showMobileActions, from, isAggregate, actualSource, actualId, searchFavorited, checkSearchFavoriteStatus]); - - // 长按手势hook - const longPressProps = useLongPress({ - onLongPress: handleLongPress, - onClick: handleClick, // 保持点击播放功能 - longPressDelay: 500, - }); - - const config = useMemo(() => { - const configs = { - playrecord: { - showSourceName: true, - showProgress: true, - showPlayButton: true, - showHeart: true, - showCheckCircle: true, - showDoubanLink: false, - showRating: false, - showYear: false, - }, - favorite: { - showSourceName: true, - showProgress: false, - showPlayButton: true, - showHeart: true, - showCheckCircle: false, - showDoubanLink: false, - showRating: false, - showYear: false, - }, - search: { - showSourceName: true, - showProgress: false, - showPlayButton: true, - showHeart: true, // 移动端菜单中需要显示收藏选项 - showCheckCircle: false, - showDoubanLink: true, // 移动端菜单中显示豆瓣链接 - showRating: false, - showYear: true, - }, - douban: { - showSourceName: false, - showProgress: false, - showPlayButton: true, - showHeart: false, - showCheckCircle: false, - showDoubanLink: true, - showRating: !!rate, - showYear: false, - }, - shortdrama: { - showSourceName: true, - showProgress: false, - showPlayButton: true, - showHeart: false, // 短剧不显示收藏功能 - showCheckCircle: false, - showDoubanLink: false, - showRating: !!rate, - showYear: true, - }, - }; - return configs[from] || configs.search; - }, [from, isAggregate, douban_id, rate]); - - // 移动端操作菜单配置 - const mobileActions = useMemo(() => { - const actions = []; - - // 播放操作 - if (config.showPlayButton) { - actions.push({ - id: 'play', - label: origin === 'live' ? '观看直播' : '播放', - icon: , - onClick: handleClick, - color: 'primary' as const, - }); - - // 新标签页播放 - actions.push({ - id: 'play-new-tab', - label: origin === 'live' ? '新标签页观看' : '新标签页播放', - icon: , - onClick: handlePlayInNewTab, - color: 'default' as const, - }); - } - - // 聚合源信息 - 直接在菜单中展示,不需要单独的操作项 - - // 收藏/取消收藏操作 - if (config.showHeart && from !== 'douban' && from !== 'shortdrama' && actualSource && actualId) { - const currentFavorited = from === 'search' ? searchFavorited : favorited; - - if (from === 'search') { - // 搜索结果:根据加载状态显示不同的选项 - if (searchFavorited !== null) { - // 已加载完成,显示实际的收藏状态 + if (from === 'search') { + // 搜索结果:根据加载状态显示不同的选项 + if (searchFavorited !== null) { + // 已加载完成,显示实际的收藏状态 + actions.push({ + id: 'favorite', + label: currentFavorited ? '取消收藏' : '添加收藏', + icon: currentFavorited ? ( + + ) : ( + + ), + onClick: () => { + const mockEvent = { + preventDefault: () => {}, + stopPropagation: () => {}, + } as React.MouseEvent; + handleToggleFavorite(mockEvent); + }, + color: currentFavorited + ? ('danger' as const) + : ('default' as const), + }); + } else { + // 正在加载中,显示占位项 + actions.push({ + id: 'favorite-loading', + label: '收藏加载中...', + icon: , + onClick: () => {}, // 加载中时不响应点击 + disabled: true, + }); + } + } else { + // 非搜索结果:直接显示收藏选项 actions.push({ id: 'favorite', label: currentFavorited ? '取消收藏' : '添加收藏', icon: currentFavorited ? ( - + ) : ( - + ), onClick: () => { const mockEvent = { - preventDefault: () => { }, - stopPropagation: () => { }, + preventDefault: () => {}, + stopPropagation: () => {}, } as React.MouseEvent; handleToggleFavorite(mockEvent); }, - color: currentFavorited ? ('danger' as const) : ('default' as const), - }); - } else { - // 正在加载中,显示占位项 - actions.push({ - id: 'favorite-loading', - label: '收藏加载中...', - icon: , - onClick: () => { }, // 加载中时不响应点击 - disabled: true, + color: currentFavorited + ? ('danger' as const) + : ('default' as const), }); } - } else { - // 非搜索结果:直接显示收藏选项 + } + + // 删除播放记录操作 + if ( + config.showCheckCircle && + from === 'playrecord' && + actualSource && + actualId + ) { actions.push({ - id: 'favorite', - label: currentFavorited ? '取消收藏' : '添加收藏', - icon: currentFavorited ? ( - - ) : ( - - ), + id: 'delete', + label: '删除记录', + icon: , onClick: () => { const mockEvent = { - preventDefault: () => { }, - stopPropagation: () => { }, + preventDefault: () => {}, + stopPropagation: () => {}, } as React.MouseEvent; - handleToggleFavorite(mockEvent); + handleDeleteRecord(mockEvent); }, - color: currentFavorited ? ('danger' as const) : ('default' as const), + color: 'danger' as const, }); } - } - // 删除播放记录操作 - if (config.showCheckCircle && from === 'playrecord' && actualSource && actualId) { - actions.push({ - id: 'delete', - label: '删除记录', - icon: , - onClick: () => { - const mockEvent = { - preventDefault: () => { }, - stopPropagation: () => { }, - } as React.MouseEvent; - handleDeleteRecord(mockEvent); - }, - color: 'danger' as const, - }); - } + // 豆瓣链接操作 + if (config.showDoubanLink && actualDoubanId && actualDoubanId !== 0) { + actions.push({ + id: 'douban', + label: isBangumi ? 'Bangumi 详情' : '豆瓣详情', + icon: , + onClick: () => { + const url = isBangumi + ? `https://bgm.tv/subject/${actualDoubanId.toString()}` + : `https://movie.douban.com/subject/${actualDoubanId.toString()}`; + window.open(url, '_blank', 'noopener,noreferrer'); + }, + color: 'default' as const, + }); + } - // 豆瓣链接操作 - if (config.showDoubanLink && actualDoubanId && actualDoubanId !== 0) { - actions.push({ - id: 'douban', - label: isBangumi ? 'Bangumi 详情' : '豆瓣详情', - icon: , - onClick: () => { - const url = isBangumi - ? `https://bgm.tv/subject/${actualDoubanId.toString()}` - : `https://movie.douban.com/subject/${actualDoubanId.toString()}`; - window.open(url, '_blank', 'noopener,noreferrer'); - }, - color: 'default' as const, - }); - } + return actions; + }, [ + config, + from, + actualSource, + actualId, + favorited, + searchFavorited, + actualDoubanId, + isBangumi, + isAggregate, + dynamicSourceNames, + handleClick, + handleToggleFavorite, + handleDeleteRecord, + ]); - return actions; - }, [ - config, - from, - actualSource, - actualId, - favorited, - searchFavorited, - actualDoubanId, - isBangumi, - isAggregate, - dynamicSourceNames, - handleClick, - handleToggleFavorite, - handleDeleteRecord, - ]); - - return ( - <> - { - // 阻止默认右键菜单 - e.preventDefault(); - e.stopPropagation(); - - // 右键弹出操作菜单 - setShowMobileActions(true); - - // 异步检查收藏状态,不阻塞菜单显示 - if (from === 'search' && !isAggregate && actualSource && actualId && searchFavorited === null) { - checkSearchFavoriteStatus(); - } - - return false; - }} - - onDragStart={(e) => { - // 阻止拖拽 - e.preventDefault(); - return false; - }} - > - {/* 海报容器 */} + return ( + <> { + // 阻止默认右键菜单 + e.preventDefault(); + e.stopPropagation(); + + // 右键弹出操作菜单 + setShowMobileActions(true); + + // 异步检查收藏状态,不阻塞菜单显示 + if ( + from === 'search' && + !isAggregate && + actualSource && + actualId && + searchFavorited === null + ) { + checkSearchFavoriteStatus(); + } + + return false; + }} + onDragStart={(e) => { + // 阻止拖拽 e.preventDefault(); return false; }} > - {/* 骨架屏 */} - {!isLoading && } - {/* 图片 - 只在有 poster 时渲染 */} - {actualPoster && ( - {actualTitle} setIsLoading(true)} - onError={(e) => { - // 图片加载失败时的重试机制 - const img = e.target as HTMLImageElement; - if (!img.dataset.retried) { - img.dataset.retried = 'true'; - setTimeout(() => { - if (actualPoster) { - img.src = processImageUrl(actualPoster); - } - }, 2000); - } - }} - style={{ - // 禁用图片的默认长按效果 + {/* 海报容器 */} + - )} - - {/* 悬浮遮罩 */} -
{ - e.preventDefault(); - return false; - }} - /> - - {/* 播放按钮 */} - {config.showPlayButton && ( -
{ - e.preventDefault(); - return false; - }} - > - { - e.preventDefault(); - return false; - }} - /> -
- )} - - {/* 操作按钮 */} - {(config.showHeart || config.showCheckCircle) && ( -
{ - e.preventDefault(); - return false; - }} - > - {config.showCheckCircle && ( - - )} - {config.showHeart && from !== 'search' && from !== 'shortdrama' && ( - - )} -
- )} - - {/* 年份徽章 */} - {config.showYear && actualYear && actualYear !== 'unknown' && actualYear.trim() !== '' && ( - { - e.preventDefault(); - return false; - }} - > - {actualYear} - - )} - - {/* 徽章 */} - {config.showRating && rate && ( - { - e.preventDefault(); - return false; - }} - > - {rate} - - )} - - {actualEpisodes && actualEpisodes > 1 && ( - { - e.preventDefault(); - return false; - }} - > - {currentEpisode - ? `${currentEpisode}/${actualEpisodes}` - : actualEpisodes} - - )} - - {/* 豆瓣链接 */} - {config.showDoubanLink && actualDoubanId && actualDoubanId !== 0 && ( - e.stopPropagation()} - className='absolute top-2 left-2 opacity-0 -translate-x-2 transition-all duration-300 ease-in-out delay-100 sm:group-hover:opacity-100 sm:group-hover:translate-x-0' - style={{ - WebkitUserSelect: 'none', - userSelect: 'none', - WebkitTouchCallout: 'none', - } as React.CSSProperties} - onContextMenu={(e) => { - e.preventDefault(); - return false; - }} - > - - - )} - - {/* 聚合播放源指示器 */} - {isAggregate && dynamicSourceNames && dynamicSourceNames.length > 0 && (() => { - const uniqueSources = Array.from(new Set(dynamicSourceNames)); - const sourceCount = uniqueSources.length; - - return ( -
{ - e.preventDefault(); - return false; - }} - > -
- { - e.preventDefault(); - return false; - }} - > - {sourceCount} - - - {/* 播放源详情悬浮框 */} - {(() => { - // 优先显示的播放源(常见的主流平台) - const prioritySources = ['爱奇艺', '腾讯视频', '优酷', '芒果TV', '哔哩哔哩', 'Netflix', 'Disney+']; - - // 按优先级排序播放源 - const sortedSources = uniqueSources.sort((a, b) => { - const aIndex = prioritySources.indexOf(a); - const bIndex = prioritySources.indexOf(b); - if (aIndex !== -1 && bIndex !== -1) return aIndex - bIndex; - if (aIndex !== -1) return -1; - if (bIndex !== -1) return 1; - return a.localeCompare(b); - }); - - const maxDisplayCount = 6; // 最多显示6个 - const displaySources = sortedSources.slice(0, maxDisplayCount); - const hasMore = sortedSources.length > maxDisplayCount; - const remainingCount = sortedSources.length - maxDisplayCount; - - return ( -
{ - e.preventDefault(); - return false; - }} - > -
{ - e.preventDefault(); - return false; - }} - > - {/* 单列布局 */} -
- {displaySources.map((sourceName, index) => ( -
-
- - {sourceName} - -
- ))} -
- - {/* 显示更多提示 */} - {hasMore && ( -
-
- +{remainingCount} 播放源 -
-
- )} - - {/* 小箭头 */} -
-
-
- ); - })()} -
-
- ); - })()} - - - {/* 进度条 */} - {config.showProgress && progress !== undefined && ( - { e.preventDefault(); return false; }} > - - - - - )} + {/* 骨架屏 */} + {!isLoading && } + {/* 图片 - 只在有 poster 时渲染 */} + {actualPoster && ( + {actualTitle} setIsLoading(true)} + onError={(e) => { + // 图片加载失败时的重试机制 + const img = e.target as HTMLImageElement; + if (!img.dataset.retried) { + img.dataset.retried = 'true'; + setTimeout(() => { + if (actualPoster) { + img.src = processImageUrl(actualPoster); + } + }, 2000); + } + }} + style={ + { + // 禁用图片的默认长按效果 + WebkitUserSelect: 'none', + userSelect: 'none', + WebkitTouchCallout: 'none', + pointerEvents: 'none', // 图片不响应任何指针事件 + } as React.CSSProperties + } + /> + )} - {/* 标题与来源 */} -
{ - e.preventDefault(); - return false; - }} - > - - + {/* 悬浮遮罩 */}
- { e.preventDefault(); return false; }} - > - {actualTitle} - -
-
- - {actualTitle} - -
- {config.showSourceName && source_name && ( - + + {/* 播放按钮 */} + {config.showPlayButton && ( +
{ + e.preventDefault(); + return false; + }} + > + { + e.preventDefault(); + return false; + }} + /> +
+ )} + + {/* 操作按钮 */} + {(config.showHeart || config.showCheckCircle) && ( +
{ + e.preventDefault(); + return false; + }} + > + {config.showCheckCircle && ( + + )} + {config.showHeart && + from !== 'search' && + from !== 'shortdrama' && ( + + )} +
+ )} + + {/* 年份徽章 */} + {config.showYear && + actualYear && + actualYear !== 'unknown' && + actualYear.trim() !== '' && ( + { + e.preventDefault(); + return false; + }} + > + {actualYear} + + )} + + {/* 徽章 */} + {config.showRating && rate && ( + { + e.preventDefault(); + return false; + }} + > + {rate} + + )} + + {actualEpisodes && actualEpisodes > 1 && ( + { + e.preventDefault(); + return false; + }} + > + + {currentEpisode + ? `${currentEpisode}/${actualEpisodes}` + : actualEpisodes} + + + )} + + {/* 豆瓣链接 */} + {config.showDoubanLink && + actualDoubanId && + actualDoubanId !== 0 && ( + e.stopPropagation()} + className='absolute top-2 left-2 opacity-0 -translate-x-2 transition-all duration-300 ease-in-out delay-100 sm:group-hover:opacity-100 sm:group-hover:translate-x-0' + style={ + { + WebkitUserSelect: 'none', + userSelect: 'none', + WebkitTouchCallout: 'none', + } as React.CSSProperties + } + onContextMenu={(e) => { + e.preventDefault(); + return false; + }} + > + + + )} + + {/* 聚合播放源指示器 */} + {isAggregate && + dynamicSourceNames && + dynamicSourceNames.length > 0 && + (() => { + const uniqueSources = Array.from(new Set(dynamicSourceNames)); + const sourceCount = uniqueSources.length; + + return ( +
{ + e.preventDefault(); + return false; + }} + > +
+ { + e.preventDefault(); + return false; + }} + > + {sourceCount} + + + {/* 播放源详情悬浮框 */} + {(() => { + // 优先显示的播放源(常见的主流平台) + const prioritySources = [ + '爱奇艺', + '腾讯视频', + '优酷', + '芒果TV', + '哔哩哔哩', + 'Netflix', + 'Disney+', + ]; + + // 按优先级排序播放源 + const sortedSources = uniqueSources.sort((a, b) => { + const aIndex = prioritySources.indexOf(a); + const bIndex = prioritySources.indexOf(b); + if (aIndex !== -1 && bIndex !== -1) + return aIndex - bIndex; + if (aIndex !== -1) return -1; + if (bIndex !== -1) return 1; + return a.localeCompare(b); + }); + + const maxDisplayCount = 6; // 最多显示6个 + const displaySources = sortedSources.slice( + 0, + maxDisplayCount + ); + const hasMore = sortedSources.length > maxDisplayCount; + const remainingCount = + sortedSources.length - maxDisplayCount; + + return ( +
{ + e.preventDefault(); + return false; + }} + > +
{ + e.preventDefault(); + return false; + }} + > + {/* 单列布局 */} +
+ {displaySources.map((sourceName, index) => ( +
+
+ + {sourceName} + +
+ ))} +
+ + {/* 显示更多提示 */} + {hasMore && ( +
+
+ + +{remainingCount} 播放源 + +
+
+ )} + + {/* 小箭头 */} +
+
+
+ ); + })()} +
+
+ ); + })()} + + + {/* 进度条 */} + {config.showProgress && progress !== undefined && ( + { e.preventDefault(); return false; }} > - {origin === 'live' && ( - - )} - {source_name} -
+ + + + )} -
- - {/* 操作菜单 - 支持右键和长按触发 */} - setShowMobileActions(false)} - title={actualTitle} - poster={processImageUrl(actualPoster)} - actions={mobileActions} - sources={isAggregate && dynamicSourceNames ? Array.from(new Set(dynamicSourceNames)) : undefined} - isAggregate={isAggregate} - sourceName={source_name} - currentEpisode={currentEpisode} - totalEpisodes={actualEpisodes} - origin={origin} - /> - - ); -} + {/* 标题与来源 */} +
{ + e.preventDefault(); + return false; + }} + > +
+ + +
+ { + e.preventDefault(); + return false; + }} + > + {actualTitle} + +
+
+ {actualTitle} +
+ {config.showSourceName && source_name && ( + { + e.preventDefault(); + return false; + }} + > + {origin === 'live' && ( + + )} + {source_name} + + )} +
+
+ + {/* 操作菜单 - 支持右键和长按触发 */} + setShowMobileActions(false)} + title={actualTitle} + poster={processImageUrl(actualPoster)} + actions={mobileActions} + sources={ + isAggregate && dynamicSourceNames + ? Array.from(new Set(dynamicSourceNames)) + : undefined + } + isAggregate={isAggregate} + sourceName={source_name} + currentEpisode={currentEpisode} + totalEpisodes={actualEpisodes} + origin={origin} + /> + + ); + } ); export default memo(VideoCard);