Tune home page layout

This commit is contained in:
leowang 2026-05-24 16:23:19 +08:00
parent 9389be8b97
commit f8eb7cea4c
5 changed files with 1244 additions and 1058 deletions

View File

@ -199,10 +199,7 @@ html {
body { body {
margin: 0; margin: 0;
background: background: rgb(var(--color-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;
color: rgb(var(--color-foreground)); color: rgb(var(--color-foreground));
font-family: var(--font-body); font-family: var(--font-body);
font-feature-settings: "tnum" 1, "cv02" 1, "cv03" 1, "cv04" 1; font-feature-settings: "tnum" 1, "cv02" 1, "cv03" 1, "cv04" 1;

View File

@ -2,7 +2,13 @@
'use client'; '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 { Suspense, useEffect, useState } from 'react';
import { import {
@ -175,9 +181,10 @@ function HomeClient() {
<CapsuleSwitch <CapsuleSwitch
options={[ options={[
{ label: '首页', value: 'home' }, { label: '首页', value: 'home' },
{ label: '收藏', value: 'favorites' }, { label: '收藏', value: 'favorites' },
]} ]}
active={activeTab} active={activeTab}
compact
onChange={(value) => setActiveTab(value as 'home' | 'favorites')} onChange={(value) => setActiveTab(value as 'home' | 'favorites')}
/> />
</div> </div>
@ -203,7 +210,7 @@ function HomeClient() {
</Button> </Button>
)} )}
</Card.Header> </Card.Header>
<div className='grid justify-start grid-cols-3 gap-x-3 gap-y-14 px-0 sm:grid-cols-[repeat(auto-fill,_minmax(11rem,_1fr))] sm:gap-x-8 sm:gap-y-20'> <div className='grid grid-cols-[repeat(auto-fill,_minmax(96px,_96px))] justify-start gap-x-3 gap-y-10 px-0 pt-2 pb-5 sm:grid-cols-[repeat(auto-fill,_minmax(180px,_180px))] sm:gap-x-5 sm:gap-y-12 sm:pb-3'>
{favoriteItems.map((item) => ( {favoriteItems.map((item) => (
<div key={item.id + item.source} className='w-full'> <div key={item.id + item.source} className='w-full'>
<VideoCard <VideoCard
@ -234,11 +241,7 @@ function HomeClient() {
<Card.Description></Card.Description> <Card.Description></Card.Description>
<Card.Title></Card.Title> <Card.Title></Card.Title>
</div> </div>
<HeroLink <HeroLink href='/douban?type=movie'></HeroLink>
href='/douban?type=movie'
>
</HeroLink>
</Card.Header> </Card.Header>
<ScrollableRow> <ScrollableRow>
{loading {loading
@ -279,9 +282,7 @@ function HomeClient() {
<Card.Description>Series</Card.Description> <Card.Description>Series</Card.Description>
<Card.Title></Card.Title> <Card.Title></Card.Title>
</div> </div>
<HeroLink href='/douban?type=tv'> <HeroLink href='/douban?type=tv'></HeroLink>
</HeroLink>
</Card.Header> </Card.Header>
<ScrollableRow> <ScrollableRow>
{loading {loading
@ -321,11 +322,7 @@ function HomeClient() {
<Card.Description>Bangumi</Card.Description> <Card.Description>Bangumi</Card.Description>
<Card.Title></Card.Title> <Card.Title></Card.Title>
</div> </div>
<HeroLink <HeroLink href='/douban?type=anime'></HeroLink>
href='/douban?type=anime'
>
</HeroLink>
</Card.Header> </Card.Header>
<ScrollableRow> <ScrollableRow>
{loading {loading
@ -394,11 +391,7 @@ function HomeClient() {
<Card.Description>Shows</Card.Description> <Card.Description>Shows</Card.Description>
<Card.Title></Card.Title> <Card.Title></Card.Title>
</div> </div>
<HeroLink <HeroLink href='/douban?type=show'></HeroLink>
href='/douban?type=show'
>
</HeroLink>
</Card.Header> </Card.Header>
<ScrollableRow> <ScrollableRow>
{loading {loading

View File

@ -7,6 +7,7 @@ interface CapsuleSwitchProps {
active: string; active: string;
onChange: (value: string) => void; onChange: (value: string) => void;
className?: string; className?: string;
compact?: boolean;
} }
const CapsuleSwitch: React.FC<CapsuleSwitchProps> = ({ const CapsuleSwitch: React.FC<CapsuleSwitchProps> = ({
@ -14,13 +15,20 @@ const CapsuleSwitch: React.FC<CapsuleSwitchProps> = ({
active, active,
onChange, onChange,
className, 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 ( return (
<AppFilterTabs <AppFilterTabs
ariaLabel='内容切换' ariaLabel='内容切换'
className={className} className={[compact ? compactClasses : '', className]
.filter(Boolean)
.join(' ')}
items={options.map((opt) => ({ key: opt.value, label: opt.label }))} items={options.map((opt) => ({ key: opt.value, label: opt.label }))}
selectedKey={active} selectedKey={active}
variant={compact ? 'primary' : 'secondary'}
onSelectionChange={onChange} onSelectionChange={onChange}
/> />
); );

View File

@ -103,7 +103,7 @@ export default function ScrollableRow({
> >
<div <div
ref={containerRef} ref={containerRef}
className='scrollbar-hide flex space-x-4 overflow-x-auto px-1 py-2 pb-12 sm:space-x-5 sm:pb-14' className='scrollbar-hide flex space-x-4 overflow-x-auto px-1 pt-2 pb-5 sm:space-x-5 sm:pb-3'
onScroll={checkScroll} onScroll={checkScroll}
> >
{children} {children}

View File

@ -1,7 +1,22 @@
/* eslint-disable @typescript-eslint/no-explicit-any,react-hooks/exhaustive-deps,@typescript-eslint/no-empty-function */ /* 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 {
import { Badge, Button, Card, Chip, Link as HeroLink, ProgressBar, Tooltip } from '@heroui/react'; 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 Image from 'next/image';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import React, { import React, {
@ -59,7 +74,8 @@ export type VideoCardHandle = {
setDoubanId: (id?: number) => void; setDoubanId: (id?: number) => void;
}; };
const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard( const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
function VideoCard(
{ {
id, id,
title = '', title = '',
@ -89,15 +105,17 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
const [favorited, setFavorited] = useState(false); const [favorited, setFavorited] = useState(false);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [showMobileActions, setShowMobileActions] = useState(false); const [showMobileActions, setShowMobileActions] = useState(false);
const [searchFavorited, setSearchFavorited] = useState<boolean | null>(null); // 搜索结果的收藏状态 const [searchFavorited, setSearchFavorited] = useState<boolean | null>(
null
); // 搜索结果的收藏状态
// 可外部修改的可控字段 // 可外部修改的可控字段
const [dynamicEpisodes, setDynamicEpisodes] = useState<number | undefined>( const [dynamicEpisodes, setDynamicEpisodes] = useState<number | undefined>(
episodes episodes
); );
const [dynamicSourceNames, setDynamicSourceNames] = useState<string[] | undefined>( const [dynamicSourceNames, setDynamicSourceNames] = useState<
source_names string[] | undefined
); >(source_names);
const [dynamicDoubanId, setDynamicDoubanId] = useState<number | undefined>( const [dynamicDoubanId, setDynamicDoubanId] = useState<number | undefined>(
douban_id douban_id
); );
@ -129,12 +147,21 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
const actualYear = year; const actualYear = year;
const actualQuery = query || ''; const actualQuery = query || '';
const actualSearchType = isAggregate const actualSearchType = isAggregate
? (actualEpisodes && actualEpisodes === 1 ? 'movie' : 'tv') ? actualEpisodes && actualEpisodes === 1
? 'movie'
: 'tv'
: type; : type;
// 获取收藏状态(搜索结果、豆瓣和短剧页面不检查) // 获取收藏状态(搜索结果、豆瓣和短剧页面不检查)
useEffect(() => { useEffect(() => {
if (from === 'douban' || from === 'search' || from === 'shortdrama' || !actualSource || !actualId) return; if (
from === 'douban' ||
from === 'search' ||
from === 'shortdrama' ||
!actualSource ||
!actualId
)
return;
const fetchFavoriteStatus = async () => { const fetchFavoriteStatus = async () => {
try { try {
@ -165,11 +192,18 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
async (e: React.MouseEvent) => { async (e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (from === 'douban' || from === 'shortdrama' || !actualSource || !actualId) return; if (
from === 'douban' ||
from === 'shortdrama' ||
!actualSource ||
!actualId
)
return;
try { try {
// 确定当前收藏状态 // 确定当前收藏状态
const currentFavorited = from === 'search' ? searchFavorited : favorited; const currentFavorited =
from === 'search' ? searchFavorited : favorited;
if (currentFavorited) { if (currentFavorited) {
// 如果已收藏,删除收藏 // 如果已收藏,删除收藏
@ -236,7 +270,10 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
if (origin === 'live' && actualSource && actualId) { if (origin === 'live' && actualSource && actualId) {
// 直播内容跳转到直播页面 // 直播内容跳转到直播页面
const url = `/live?source=${actualSource.replace('live_', '')}&id=${actualId.replace('live_', '')}`; const url = `/live?source=${actualSource.replace(
'live_',
''
)}&id=${actualId.replace('live_', '')}`;
router.push(url); router.push(url);
} else if (from === 'shortdrama' && actualId) { } else if (from === 'shortdrama' && actualId) {
// 短剧内容跳转到播放页面传递剧集ID用于调用获取全集地址的接口 // 短剧内容跳转到播放页面传递剧集ID用于调用获取全集地址的接口
@ -249,15 +286,25 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
const url = `/play?${urlParams.toString()}`; const url = `/play?${urlParams.toString()}`;
router.push(url); router.push(url);
} else if (from === 'douban' || (isAggregate && !actualSource && !actualId)) { } else if (
const url = `/play?title=${encodeURIComponent(actualTitle.trim())}${actualYear ? `&year=${actualYear}` : '' from === 'douban' ||
}${actualSearchType ? `&stype=${actualSearchType}` : ''}${isAggregate ? '&prefer=true' : ''}${actualQuery ? `&stitle=${encodeURIComponent(actualQuery.trim())}` : ''}`; (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); router.push(url);
} else if (actualSource && actualId) { } else if (actualSource && actualId) {
const url = `/play?source=${actualSource}&id=${actualId}&title=${encodeURIComponent( const url = `/play?source=${actualSource}&id=${actualId}&title=${encodeURIComponent(
actualTitle actualTitle
)}${actualYear ? `&year=${actualYear}` : ''}${isAggregate ? '&prefer=true' : '' )}${actualYear ? `&year=${actualYear}` : ''}${
}${actualQuery ? `&stitle=${encodeURIComponent(actualQuery.trim())}` : '' isAggregate ? '&prefer=true' : ''
}${
actualQuery ? `&stitle=${encodeURIComponent(actualQuery.trim())}` : ''
}${actualSearchType ? `&stype=${actualSearchType}` : ''}`; }${actualSearchType ? `&stype=${actualSearchType}` : ''}`;
router.push(url); router.push(url);
} }
@ -283,16 +330,30 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
if (origin === 'live' && actualSource && actualId) { if (origin === 'live' && actualSource && actualId) {
// 直播内容跳转到直播页面 // 直播内容跳转到直播页面
const url = `/live?source=${actualSource.replace('live_', '')}&id=${actualId.replace('live_', '')}`; const url = `/live?source=${actualSource.replace(
'live_',
''
)}&id=${actualId.replace('live_', '')}`;
window.open(url, '_blank'); window.open(url, '_blank');
} else if (from === 'douban' || (isAggregate && !actualSource && !actualId)) { } else if (
const url = `/play?title=${encodeURIComponent(actualTitle.trim())}${actualYear ? `&year=${actualYear}` : ''}${actualSearchType ? `&stype=${actualSearchType}` : ''}${isAggregate ? '&prefer=true' : ''}${actualQuery ? `&stitle=${encodeURIComponent(actualQuery.trim())}` : ''}`; 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'); window.open(url, '_blank');
} else if (actualSource && actualId) { } else if (actualSource && actualId) {
const url = `/play?source=${actualSource}&id=${actualId}&title=${encodeURIComponent( const url = `/play?source=${actualSource}&id=${actualId}&title=${encodeURIComponent(
actualTitle actualTitle
)}${actualYear ? `&year=${actualYear}` : ''}${isAggregate ? '&prefer=true' : '' )}${actualYear ? `&year=${actualYear}` : ''}${
}${actualQuery ? `&stitle=${encodeURIComponent(actualQuery.trim())}` : '' isAggregate ? '&prefer=true' : ''
}${
actualQuery ? `&stitle=${encodeURIComponent(actualQuery.trim())}` : ''
}${actualSearchType ? `&stype=${actualSearchType}` : ''}`; }${actualSearchType ? `&stype=${actualSearchType}` : ''}`;
window.open(url, '_blank'); window.open(url, '_blank');
} }
@ -310,7 +371,13 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
// 检查搜索结果的收藏状态 // 检查搜索结果的收藏状态
const checkSearchFavoriteStatus = useCallback(async () => { const checkSearchFavoriteStatus = useCallback(async () => {
if (from === 'search' && !isAggregate && actualSource && actualId && searchFavorited === null) { if (
from === 'search' &&
!isAggregate &&
actualSource &&
actualId &&
searchFavorited === null
) {
try { try {
const fav = await isFavorited(actualSource, actualId); const fav = await isFavorited(actualSource, actualId);
setSearchFavorited(fav); setSearchFavorited(fav);
@ -322,16 +389,31 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
// 长按操作 // 长按操作
const handleLongPress = useCallback(() => { const handleLongPress = useCallback(() => {
if (!showMobileActions) { // 防止重复触发 if (!showMobileActions) {
// 防止重复触发
// 立即显示菜单,避免等待数据加载导致动画卡顿 // 立即显示菜单,避免等待数据加载导致动画卡顿
setShowMobileActions(true); setShowMobileActions(true);
// 异步检查收藏状态,不阻塞菜单显示 // 异步检查收藏状态,不阻塞菜单显示
if (from === 'search' && !isAggregate && actualSource && actualId && searchFavorited === null) { if (
from === 'search' &&
!isAggregate &&
actualSource &&
actualId &&
searchFavorited === null
) {
checkSearchFavoriteStatus(); checkSearchFavoriteStatus();
} }
} }
}, [showMobileActions, from, isAggregate, actualSource, actualId, searchFavorited, checkSearchFavoriteStatus]); }, [
showMobileActions,
from,
isAggregate,
actualSource,
actualId,
searchFavorited,
checkSearchFavoriteStatus,
]);
// 长按手势hook // 长按手势hook
const longPressProps = useLongPress({ const longPressProps = useLongPress({
@ -423,8 +505,15 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
// 聚合源信息 - 直接在菜单中展示,不需要单独的操作项 // 聚合源信息 - 直接在菜单中展示,不需要单独的操作项
// 收藏/取消收藏操作 // 收藏/取消收藏操作
if (config.showHeart && from !== 'douban' && from !== 'shortdrama' && actualSource && actualId) { if (
const currentFavorited = from === 'search' ? searchFavorited : favorited; config.showHeart &&
from !== 'douban' &&
from !== 'shortdrama' &&
actualSource &&
actualId
) {
const currentFavorited =
from === 'search' ? searchFavorited : favorited;
if (from === 'search') { if (from === 'search') {
// 搜索结果:根据加载状态显示不同的选项 // 搜索结果:根据加载状态显示不同的选项
@ -434,9 +523,9 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
id: 'favorite', id: 'favorite',
label: currentFavorited ? '取消收藏' : '添加收藏', label: currentFavorited ? '取消收藏' : '添加收藏',
icon: currentFavorited ? ( icon: currentFavorited ? (
<Heart size={20} className="fill-red-600 stroke-red-600" /> <Heart size={20} className='fill-red-600 stroke-red-600' />
) : ( ) : (
<Heart size={20} className="fill-transparent stroke-red-500" /> <Heart size={20} className='fill-transparent stroke-red-500' />
), ),
onClick: () => { onClick: () => {
const mockEvent = { const mockEvent = {
@ -445,7 +534,9 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
} as React.MouseEvent; } as React.MouseEvent;
handleToggleFavorite(mockEvent); handleToggleFavorite(mockEvent);
}, },
color: currentFavorited ? ('danger' as const) : ('default' as const), color: currentFavorited
? ('danger' as const)
: ('default' as const),
}); });
} else { } else {
// 正在加载中,显示占位项 // 正在加载中,显示占位项
@ -463,9 +554,9 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
id: 'favorite', id: 'favorite',
label: currentFavorited ? '取消收藏' : '添加收藏', label: currentFavorited ? '取消收藏' : '添加收藏',
icon: currentFavorited ? ( icon: currentFavorited ? (
<Heart size={20} className="fill-red-600 stroke-red-600" /> <Heart size={20} className='fill-red-600 stroke-red-600' />
) : ( ) : (
<Heart size={20} className="fill-transparent stroke-red-500" /> <Heart size={20} className='fill-transparent stroke-red-500' />
), ),
onClick: () => { onClick: () => {
const mockEvent = { const mockEvent = {
@ -474,13 +565,20 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
} as React.MouseEvent; } as React.MouseEvent;
handleToggleFavorite(mockEvent); handleToggleFavorite(mockEvent);
}, },
color: currentFavorited ? ('danger' as const) : ('default' as const), color: currentFavorited
? ('danger' as const)
: ('default' as const),
}); });
} }
} }
// 删除播放记录操作 // 删除播放记录操作
if (config.showCheckCircle && from === 'playrecord' && actualSource && actualId) { if (
config.showCheckCircle &&
from === 'playrecord' &&
actualSource &&
actualId
) {
actions.push({ actions.push({
id: 'delete', id: 'delete',
label: '删除记录', label: '删除记录',
@ -536,7 +634,8 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
className='group relative z-0 w-full cursor-pointer overflow-visible rounded-none p-0' className='group relative z-0 w-full cursor-pointer overflow-visible rounded-none p-0'
onClick={handleClick} onClick={handleClick}
{...longPressProps} {...longPressProps}
style={{ style={
{
// 禁用所有默认的长按和选择效果 // 禁用所有默认的长按和选择效果
WebkitUserSelect: 'none', WebkitUserSelect: 'none',
userSelect: 'none', userSelect: 'none',
@ -545,7 +644,8 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
touchAction: 'manipulation', touchAction: 'manipulation',
// 禁用右键菜单和长按菜单 // 禁用右键菜单和长按菜单
pointerEvents: 'auto', pointerEvents: 'auto',
} as React.CSSProperties} } as React.CSSProperties
}
onContextMenu={(e) => { onContextMenu={(e) => {
// 阻止默认右键菜单 // 阻止默认右键菜单
e.preventDefault(); e.preventDefault();
@ -555,13 +655,18 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
setShowMobileActions(true); setShowMobileActions(true);
// 异步检查收藏状态,不阻塞菜单显示 // 异步检查收藏状态,不阻塞菜单显示
if (from === 'search' && !isAggregate && actualSource && actualId && searchFavorited === null) { if (
from === 'search' &&
!isAggregate &&
actualSource &&
actualId &&
searchFavorited === null
) {
checkSearchFavoriteStatus(); checkSearchFavoriteStatus();
} }
return false; return false;
}} }}
onDragStart={(e) => { onDragStart={(e) => {
// 阻止拖拽 // 阻止拖拽
e.preventDefault(); e.preventDefault();
@ -572,11 +677,13 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
<Card <Card
variant='default' variant='default'
className='relative aspect-[2/3] overflow-hidden rounded-lg p-0' className='relative aspect-[2/3] overflow-hidden rounded-lg p-0'
style={{ style={
{
WebkitUserSelect: 'none', WebkitUserSelect: 'none',
userSelect: 'none', userSelect: 'none',
WebkitTouchCallout: 'none', WebkitTouchCallout: 'none',
} as React.CSSProperties} } as React.CSSProperties
}
onContextMenu={(e) => { onContextMenu={(e) => {
e.preventDefault(); e.preventDefault();
return false; return false;
@ -590,7 +697,9 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
src={processImageUrl(actualPoster)} src={processImageUrl(actualPoster)}
alt={actualTitle} alt={actualTitle}
fill fill
className={origin === 'live' ? 'object-contain' : 'object-cover'} className={
origin === 'live' ? 'object-contain' : 'object-cover'
}
referrerPolicy='no-referrer' referrerPolicy='no-referrer'
loading='lazy' loading='lazy'
onLoadingComplete={() => setIsLoading(true)} onLoadingComplete={() => setIsLoading(true)}
@ -606,24 +715,28 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
}, 2000); }, 2000);
} }
}} }}
style={{ style={
{
// 禁用图片的默认长按效果 // 禁用图片的默认长按效果
WebkitUserSelect: 'none', WebkitUserSelect: 'none',
userSelect: 'none', userSelect: 'none',
WebkitTouchCallout: 'none', WebkitTouchCallout: 'none',
pointerEvents: 'none', // 图片不响应任何指针事件 pointerEvents: 'none', // 图片不响应任何指针事件
} as React.CSSProperties} } as React.CSSProperties
}
/> />
)} )}
{/* 悬浮遮罩 */} {/* 悬浮遮罩 */}
<div <div
className='absolute inset-0 bg-black/35 opacity-0 transition-opacity duration-300 ease-in-out group-hover:opacity-100' className='absolute inset-0 bg-black/35 opacity-0 transition-opacity duration-300 ease-in-out group-hover:opacity-100'
style={{ style={
{
WebkitUserSelect: 'none', WebkitUserSelect: 'none',
userSelect: 'none', userSelect: 'none',
WebkitTouchCallout: 'none', WebkitTouchCallout: 'none',
} as React.CSSProperties} } as React.CSSProperties
}
onContextMenu={(e) => { onContextMenu={(e) => {
e.preventDefault(); e.preventDefault();
return false; return false;
@ -633,13 +746,15 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
{/* 播放按钮 */} {/* 播放按钮 */}
{config.showPlayButton && ( {config.showPlayButton && (
<div <div
data-button="true" data-button='true'
className='absolute inset-0 flex items-center justify-center opacity-0 transition-all duration-300 ease-in-out delay-75 group-hover:opacity-100 group-hover:scale-100' className='absolute inset-0 flex items-center justify-center opacity-0 transition-all duration-300 ease-in-out delay-75 group-hover:opacity-100 group-hover:scale-100'
style={{ style={
{
WebkitUserSelect: 'none', WebkitUserSelect: 'none',
userSelect: 'none', userSelect: 'none',
WebkitTouchCallout: 'none', WebkitTouchCallout: 'none',
} as React.CSSProperties} } as React.CSSProperties
}
onContextMenu={(e) => { onContextMenu={(e) => {
e.preventDefault(); e.preventDefault();
return false; return false;
@ -649,11 +764,13 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
size={50} size={50}
strokeWidth={0.8} strokeWidth={0.8}
className='fill-background text-accent' className='fill-background text-accent'
style={{ style={
{
WebkitUserSelect: 'none', WebkitUserSelect: 'none',
userSelect: 'none', userSelect: 'none',
WebkitTouchCallout: 'none', WebkitTouchCallout: 'none',
} as React.CSSProperties} } as React.CSSProperties
}
onContextMenu={(e) => { onContextMenu={(e) => {
e.preventDefault(); e.preventDefault();
return false; return false;
@ -665,13 +782,15 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
{/* 操作按钮 */} {/* 操作按钮 */}
{(config.showHeart || config.showCheckCircle) && ( {(config.showHeart || config.showCheckCircle) && (
<div <div
data-button="true" data-button='true'
className='absolute bottom-3 right-3 flex gap-3 opacity-0 translate-y-2 transition-all duration-300 ease-in-out sm:group-hover:opacity-100 sm:group-hover:translate-y-0' className='absolute bottom-3 right-3 flex gap-3 opacity-0 translate-y-2 transition-all duration-300 ease-in-out sm:group-hover:opacity-100 sm:group-hover:translate-y-0'
style={{ style={
{
WebkitUserSelect: 'none', WebkitUserSelect: 'none',
userSelect: 'none', userSelect: 'none',
WebkitTouchCallout: 'none', WebkitTouchCallout: 'none',
} as React.CSSProperties} } as React.CSSProperties
}
onContextMenu={(e) => { onContextMenu={(e) => {
e.preventDefault(); e.preventDefault();
return false; return false;
@ -692,7 +811,9 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
<Trash2 size={16} /> <Trash2 size={16} />
</Button> </Button>
)} )}
{config.showHeart && from !== 'search' && from !== 'shortdrama' && ( {config.showHeart &&
from !== 'search' &&
from !== 'shortdrama' && (
<Button <Button
isIconOnly isIconOnly
size='sm' size='sm'
@ -704,23 +825,31 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
} as React.MouseEvent) } as React.MouseEvent)
} }
> >
<Heart size={16} className={favorited ? 'fill-current' : ''} /> <Heart
size={16}
className={favorited ? 'fill-current' : ''}
/>
</Button> </Button>
)} )}
</div> </div>
)} )}
{/* 年份徽章 */} {/* 年份徽章 */}
{config.showYear && actualYear && actualYear !== 'unknown' && actualYear.trim() !== '' && ( {config.showYear &&
actualYear &&
actualYear !== 'unknown' &&
actualYear.trim() !== '' && (
<Badge <Badge
size='sm' size='sm'
variant='secondary' variant='secondary'
className='absolute left-2 top-2' className='absolute left-2 top-2'
style={{ style={
{
WebkitUserSelect: 'none', WebkitUserSelect: 'none',
userSelect: 'none', userSelect: 'none',
WebkitTouchCallout: 'none', WebkitTouchCallout: 'none',
} as React.CSSProperties} } as React.CSSProperties
}
onContextMenu={(e) => { onContextMenu={(e) => {
e.preventDefault(); e.preventDefault();
return false; return false;
@ -737,11 +866,13 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
color='accent' color='accent'
variant='primary' variant='primary'
className='absolute right-2 top-2' className='absolute right-2 top-2'
style={{ style={
{
WebkitUserSelect: 'none', WebkitUserSelect: 'none',
userSelect: 'none', userSelect: 'none',
WebkitTouchCallout: 'none', WebkitTouchCallout: 'none',
} as React.CSSProperties} } as React.CSSProperties
}
onContextMenu={(e) => { onContextMenu={(e) => {
e.preventDefault(); e.preventDefault();
return false; return false;
@ -752,28 +883,34 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
)} )}
{actualEpisodes && actualEpisodes > 1 && ( {actualEpisodes && actualEpisodes > 1 && (
<Badge <Chip
size='sm' size='md'
variant='secondary' variant='secondary'
className='absolute right-2 top-2' className='absolute right-3 top-3 min-w-12 justify-center'
style={{ style={
{
WebkitUserSelect: 'none', WebkitUserSelect: 'none',
userSelect: 'none', userSelect: 'none',
WebkitTouchCallout: 'none', WebkitTouchCallout: 'none',
} as React.CSSProperties} } as React.CSSProperties
}
onContextMenu={(e) => { onContextMenu={(e) => {
e.preventDefault(); e.preventDefault();
return false; return false;
}} }}
> >
<Badge.Label>{currentEpisode <Chip.Label>
{currentEpisode
? `${currentEpisode}/${actualEpisodes}` ? `${currentEpisode}/${actualEpisodes}`
: actualEpisodes}</Badge.Label> : actualEpisodes}
</Badge> </Chip.Label>
</Chip>
)} )}
{/* 豆瓣链接 */} {/* 豆瓣链接 */}
{config.showDoubanLink && actualDoubanId && actualDoubanId !== 0 && ( {config.showDoubanLink &&
actualDoubanId &&
actualDoubanId !== 0 && (
<HeroLink <HeroLink
href={ href={
isBangumi isBangumi
@ -784,11 +921,13 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
rel='noopener noreferrer' rel='noopener noreferrer'
onClick={(e) => e.stopPropagation()} onClick={(e) => 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' 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={{ style={
{
WebkitUserSelect: 'none', WebkitUserSelect: 'none',
userSelect: 'none', userSelect: 'none',
WebkitTouchCallout: 'none', WebkitTouchCallout: 'none',
} as React.CSSProperties} } as React.CSSProperties
}
onContextMenu={(e) => { onContextMenu={(e) => {
e.preventDefault(); e.preventDefault();
return false; return false;
@ -796,29 +935,36 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
> >
<LinkIcon <LinkIcon
size={18} size={18}
style={{ style={
{
WebkitUserSelect: 'none', WebkitUserSelect: 'none',
userSelect: 'none', userSelect: 'none',
WebkitTouchCallout: 'none', WebkitTouchCallout: 'none',
pointerEvents: 'none', pointerEvents: 'none',
} as React.CSSProperties} } as React.CSSProperties
}
/> />
</HeroLink> </HeroLink>
)} )}
{/* 聚合播放源指示器 */} {/* 聚合播放源指示器 */}
{isAggregate && dynamicSourceNames && dynamicSourceNames.length > 0 && (() => { {isAggregate &&
dynamicSourceNames &&
dynamicSourceNames.length > 0 &&
(() => {
const uniqueSources = Array.from(new Set(dynamicSourceNames)); const uniqueSources = Array.from(new Set(dynamicSourceNames));
const sourceCount = uniqueSources.length; const sourceCount = uniqueSources.length;
return ( return (
<div <div
className='absolute bottom-2 right-2 opacity-0 transition-all duration-300 ease-in-out delay-75 sm:group-hover:opacity-100' className='absolute bottom-2 right-2 opacity-0 transition-all duration-300 ease-in-out delay-75 sm:group-hover:opacity-100'
style={{ style={
{
WebkitUserSelect: 'none', WebkitUserSelect: 'none',
userSelect: 'none', userSelect: 'none',
WebkitTouchCallout: 'none', WebkitTouchCallout: 'none',
} as React.CSSProperties} } as React.CSSProperties
}
onContextMenu={(e) => { onContextMenu={(e) => {
e.preventDefault(); e.preventDefault();
return false; return false;
@ -826,21 +972,25 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
> >
<div <div
className='relative group/sources' className='relative group/sources'
style={{ style={
{
WebkitUserSelect: 'none', WebkitUserSelect: 'none',
userSelect: 'none', userSelect: 'none',
WebkitTouchCallout: 'none', WebkitTouchCallout: 'none',
} as React.CSSProperties} } as React.CSSProperties
}
> >
<Badge <Badge
size='sm' size='sm'
color='accent' color='accent'
variant='secondary' variant='secondary'
style={{ style={
{
WebkitUserSelect: 'none', WebkitUserSelect: 'none',
userSelect: 'none', userSelect: 'none',
WebkitTouchCallout: 'none', WebkitTouchCallout: 'none',
} as React.CSSProperties} } as React.CSSProperties
}
onContextMenu={(e) => { onContextMenu={(e) => {
e.preventDefault(); e.preventDefault();
return false; return false;
@ -852,31 +1002,46 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
{/* 播放源详情悬浮框 */} {/* 播放源详情悬浮框 */}
{(() => { {(() => {
// 优先显示的播放源(常见的主流平台) // 优先显示的播放源(常见的主流平台)
const prioritySources = ['爱奇艺', '腾讯视频', '优酷', '芒果TV', '哔哩哔哩', 'Netflix', 'Disney+']; const prioritySources = [
'爱奇艺',
'腾讯视频',
'优酷',
'芒果TV',
'哔哩哔哩',
'Netflix',
'Disney+',
];
// 按优先级排序播放源 // 按优先级排序播放源
const sortedSources = uniqueSources.sort((a, b) => { const sortedSources = uniqueSources.sort((a, b) => {
const aIndex = prioritySources.indexOf(a); const aIndex = prioritySources.indexOf(a);
const bIndex = prioritySources.indexOf(b); const bIndex = prioritySources.indexOf(b);
if (aIndex !== -1 && bIndex !== -1) return aIndex - bIndex; if (aIndex !== -1 && bIndex !== -1)
return aIndex - bIndex;
if (aIndex !== -1) return -1; if (aIndex !== -1) return -1;
if (bIndex !== -1) return 1; if (bIndex !== -1) return 1;
return a.localeCompare(b); return a.localeCompare(b);
}); });
const maxDisplayCount = 6; // 最多显示6个 const maxDisplayCount = 6; // 最多显示6个
const displaySources = sortedSources.slice(0, maxDisplayCount); const displaySources = sortedSources.slice(
0,
maxDisplayCount
);
const hasMore = sortedSources.length > maxDisplayCount; const hasMore = sortedSources.length > maxDisplayCount;
const remainingCount = sortedSources.length - maxDisplayCount; const remainingCount =
sortedSources.length - maxDisplayCount;
return ( return (
<div <div
className='absolute bottom-full mb-2 opacity-0 invisible group-hover/sources:opacity-100 group-hover/sources:visible transition-all duration-200 ease-out delay-100 pointer-events-none z-50 right-0 sm:right-0 -translate-x-0 sm:translate-x-0' className='absolute bottom-full mb-2 opacity-0 invisible group-hover/sources:opacity-100 group-hover/sources:visible transition-all duration-200 ease-out delay-100 pointer-events-none z-50 right-0 sm:right-0 -translate-x-0 sm:translate-x-0'
style={{ style={
{
WebkitUserSelect: 'none', WebkitUserSelect: 'none',
userSelect: 'none', userSelect: 'none',
WebkitTouchCallout: 'none', WebkitTouchCallout: 'none',
} as React.CSSProperties} } as React.CSSProperties
}
onContextMenu={(e) => { onContextMenu={(e) => {
e.preventDefault(); e.preventDefault();
return false; return false;
@ -884,11 +1049,13 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
> >
<div <div
className='min-w-[100px] max-w-[140px] overflow-hidden rounded-xl border border-border/70 bg-overlay/95 p-1.5 text-xs text-foreground shadow-xl backdrop-blur sm:min-w-[120px] sm:max-w-[200px] sm:p-2' className='min-w-[100px] max-w-[140px] overflow-hidden rounded-xl border border-border/70 bg-overlay/95 p-1.5 text-xs text-foreground shadow-xl backdrop-blur sm:min-w-[120px] sm:max-w-[200px] sm:p-2'
style={{ style={
{
WebkitUserSelect: 'none', WebkitUserSelect: 'none',
userSelect: 'none', userSelect: 'none',
WebkitTouchCallout: 'none', WebkitTouchCallout: 'none',
} as React.CSSProperties} } as React.CSSProperties
}
onContextMenu={(e) => { onContextMenu={(e) => {
e.preventDefault(); e.preventDefault();
return false; return false;
@ -897,9 +1064,15 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
{/* 单列布局 */} {/* 单列布局 */}
<div className='space-y-0.5 sm:space-y-1'> <div className='space-y-0.5 sm:space-y-1'>
{displaySources.map((sourceName, index) => ( {displaySources.map((sourceName, index) => (
<div key={index} className='flex items-center gap-1 sm:gap-1.5'> <div
key={index}
className='flex items-center gap-1 sm:gap-1.5'
>
<div className='h-3 w-1 rounded-full flex-shrink-0 bg-accent/70'></div> <div className='h-3 w-1 rounded-full flex-shrink-0 bg-accent/70'></div>
<span className='truncate text-[10px] sm:text-xs leading-tight' title={sourceName}> <span
className='truncate text-[10px] sm:text-xs leading-tight'
title={sourceName}
>
{sourceName} {sourceName}
</span> </span>
</div> </div>
@ -910,7 +1083,9 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
{hasMore && ( {hasMore && (
<div className='mt-1 border-t border-border/70 pt-1 sm:mt-2 sm:pt-1.5'> <div className='mt-1 border-t border-border/70 pt-1 sm:mt-2 sm:pt-1.5'>
<div className='flex items-center justify-center text-muted'> <div className='flex items-center justify-center text-muted'>
<span className='text-[10px] sm:text-xs font-medium'>+{remainingCount} </span> <span className='text-[10px] sm:text-xs font-medium'>
+{remainingCount}
</span>
</div> </div>
</div> </div>
)} )}
@ -935,11 +1110,13 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
className='mt-2' className='mt-2'
size='sm' size='sm'
color='accent' color='accent'
style={{ style={
{
WebkitUserSelect: 'none', WebkitUserSelect: 'none',
userSelect: 'none', userSelect: 'none',
WebkitTouchCallout: 'none', WebkitTouchCallout: 'none',
} as React.CSSProperties} } as React.CSSProperties
}
onContextMenu={(e) => { onContextMenu={(e) => {
e.preventDefault(); e.preventDefault();
return false; return false;
@ -954,33 +1131,40 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
{/* 标题与来源 */} {/* 标题与来源 */}
<div <div
className='mt-3 text-left' className='mt-3 text-left'
style={{ style={
{
WebkitUserSelect: 'none', WebkitUserSelect: 'none',
userSelect: 'none', userSelect: 'none',
WebkitTouchCallout: 'none', WebkitTouchCallout: 'none',
} as React.CSSProperties} } as React.CSSProperties
}
onContextMenu={(e) => { onContextMenu={(e) => {
e.preventDefault(); e.preventDefault();
return false; return false;
}} }}
> >
<div className='flex min-w-0 items-center gap-2'>
<Tooltip> <Tooltip>
<Tooltip.Trigger> <Tooltip.Trigger>
<div <div
className='relative' className='relative min-w-0 flex-1'
style={{ style={
{
WebkitUserSelect: 'none', WebkitUserSelect: 'none',
userSelect: 'none', userSelect: 'none',
WebkitTouchCallout: 'none', WebkitTouchCallout: 'none',
} as React.CSSProperties} } as React.CSSProperties
}
> >
<span <span
className='block truncate text-sm font-semibold' className='block truncate text-sm font-semibold'
style={{ style={
{
WebkitUserSelect: 'none', WebkitUserSelect: 'none',
userSelect: 'none', userSelect: 'none',
WebkitTouchCallout: 'none', WebkitTouchCallout: 'none',
} as React.CSSProperties} } as React.CSSProperties
}
onContextMenu={(e) => { onContextMenu={(e) => {
e.preventDefault(); e.preventDefault();
return false; return false;
@ -990,33 +1174,34 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
</span> </span>
</div> </div>
</Tooltip.Trigger> </Tooltip.Trigger>
<Tooltip.Content placement='top'> <Tooltip.Content placement='top'>{actualTitle}</Tooltip.Content>
{actualTitle}
</Tooltip.Content>
</Tooltip> </Tooltip>
{config.showSourceName && source_name && ( {config.showSourceName && source_name && (
<Chip <Chip
size='sm' size='sm'
color='accent' color='accent'
variant='soft' variant='soft'
className='mt-1' className='shrink-0'
style={{ style={
{
WebkitUserSelect: 'none', WebkitUserSelect: 'none',
userSelect: 'none', userSelect: 'none',
WebkitTouchCallout: 'none', WebkitTouchCallout: 'none',
} as React.CSSProperties} } as React.CSSProperties
}
onContextMenu={(e) => { onContextMenu={(e) => {
e.preventDefault(); e.preventDefault();
return false; return false;
}} }}
> >
{origin === 'live' && ( {origin === 'live' && (
<Radio size={12} className="inline-block mr-1 text-muted" /> <Radio size={12} className='inline-block mr-1 text-muted' />
)} )}
<Chip.Label>{source_name}</Chip.Label> <Chip.Label>{source_name}</Chip.Label>
</Chip> </Chip>
)} )}
</div> </div>
</div>
</Card> </Card>
{/* 操作菜单 - 支持右键和长按触发 */} {/* 操作菜单 - 支持右键和长按触发 */}
@ -1026,7 +1211,11 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
title={actualTitle} title={actualTitle}
poster={processImageUrl(actualPoster)} poster={processImageUrl(actualPoster)}
actions={mobileActions} actions={mobileActions}
sources={isAggregate && dynamicSourceNames ? Array.from(new Set(dynamicSourceNames)) : undefined} sources={
isAggregate && dynamicSourceNames
? Array.from(new Set(dynamicSourceNames))
: undefined
}
isAggregate={isAggregate} isAggregate={isAggregate}
sourceName={source_name} sourceName={source_name}
currentEpisode={currentEpisode} currentEpisode={currentEpisode}
@ -1036,7 +1225,6 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
</> </>
); );
} }
); );
export default memo(VideoCard); export default memo(VideoCard);