From c01f75d228bf784dfb63ae63376105372274f4c5 Mon Sep 17 00:00:00 2001 From: shinya Date: Sat, 5 Jul 2025 01:47:44 +0800 Subject: [PATCH 1/3] feat: card hover style --- src/components/AggregateCard.tsx | 77 ++++++++++++++++++-- src/components/DemoCard.tsx | 119 ++++++++++++++++++++++++++----- src/components/VideoCard.tsx | 78 +++++++++++++++++--- 3 files changed, 242 insertions(+), 32 deletions(-) diff --git a/src/components/AggregateCard.tsx b/src/components/AggregateCard.tsx index ca5730f..f9bb1c7 100644 --- a/src/components/AggregateCard.tsx +++ b/src/components/AggregateCard.tsx @@ -1,8 +1,10 @@ +/* eslint-disable react-hooks/exhaustive-deps */ + import { LinkIcon } from 'lucide-react'; import Image from 'next/image'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; -import React, { useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { ImagePlaceholder } from '@/components/ImagePlaceholder'; @@ -69,6 +71,55 @@ const AggregateCard: React.FC = ({ const [isLoaded, setIsLoaded] = useState(false); const router = useRouter(); + // 视差位置偏移 + const [parallax, setParallax] = useState({ x: 0, y: 0 }); + const cardRef = useRef(null); + + // 图片视差效果 + useEffect(() => { + let requestId: number | null = null; + let lastX = 0; + let lastY = 0; + + const handleMouseMove = (e: MouseEvent) => { + if (!cardRef.current) return; + + const rect = cardRef.current.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + if (Math.abs(x - lastX) > 5 || Math.abs(y - lastY) > 5) { + lastX = x; + lastY = y; + + if (requestId) cancelAnimationFrame(requestId); + requestId = requestAnimationFrame(() => { + const xParallax = (x / rect.width - 0.5) * 10; + const yParallax = (y / rect.height - 0.5) * 10; + setParallax({ x: xParallax, y: yParallax }); + }); + } + }; + + const handleMouseLeave = () => { + if (requestId) cancelAnimationFrame(requestId); + setParallax({ x: 0, y: 0 }); + }; + + if (cardRef.current) { + cardRef.current.addEventListener('mousemove', handleMouseMove); + cardRef.current.addEventListener('mouseleave', handleMouseLeave); + } + + return () => { + if (cardRef.current) { + cardRef.current.removeEventListener('mousemove', handleMouseMove); + cardRef.current.removeEventListener('mouseleave', handleMouseLeave); + } + if (requestId) cancelAnimationFrame(requestId); + }; + }, []); + // 统计 items 中出现次数最多的(非 0) douban_id,用于跳转豆瓣页面 const mostFrequentDoubanId = useMemo(() => { const countMap = new Map(); @@ -124,7 +175,10 @@ const AggregateCard: React.FC = ({ year ? `&year=${encodeURIComponent(year)}` : '' }&type=${mostFrequentEpisodes > 1 ? 'tv' : 'movie'}`} > -
+
{/* 封面图片 2:3 */}
{/* 图片占位符 - 骨架屏效果 */} @@ -134,7 +188,7 @@ const AggregateCard: React.FC = ({ src={first.poster} alt={first.title} fill - className={`object-cover transition-transform duration-500 cubic-bezier(0.4,0,0.2,1) group-hover:scale-110 + className={`object-cover transition-all duration-700 cubic-bezier(0.34,1.56,0.64,1) ${ isLoaded ? 'opacity-100 scale-100' @@ -143,6 +197,13 @@ const AggregateCard: React.FC = ({ onLoadingComplete={() => setIsLoaded(true)} referrerPolicy='no-referrer' priority={false} + style={{ + transform: `scale(1.05) translate(${parallax.x}px, ${parallax.y}px)`, + transition: 'transform 0.5s cubic-bezier(0.34,1.56,0.64,1)', + willChange: 'transform', + backfaceVisibility: 'hidden', + perspective: '1000px', + }} /> {/* Hover 效果层 */} @@ -150,8 +211,8 @@ const AggregateCard: React.FC = ({ {/* 播放按钮 */}
{ @@ -199,8 +260,10 @@ const AggregateCard: React.FC = ({
{/* 标题 */} - - {first.title} + + + {first.title} +
diff --git a/src/components/DemoCard.tsx b/src/components/DemoCard.tsx index f5a19ee..04229cf 100644 --- a/src/components/DemoCard.tsx +++ b/src/components/DemoCard.tsx @@ -1,7 +1,9 @@ +/* eslint-disable react-hooks/exhaustive-deps */ + import { Link as LinkIcon, Search } from 'lucide-react'; import Image from 'next/image'; import { useRouter } from 'next/navigation'; -import React, { useRef, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { ImagePlaceholder } from '@/components/ImagePlaceholder'; @@ -13,12 +15,15 @@ interface DemoCardProps { type?: string; } +// 优化的搜索图标组件,添加更多动画 function SearchCircle({ className = '', fillColor = 'none', + isHovered = false, }: { className?: string; fillColor?: string; + isHovered?: boolean; }) { return (
- +
@@ -49,8 +66,59 @@ function SearchCircle({ const DemoCard = ({ id, title, poster, rate, type }: DemoCardProps) => { const [hover, setHover] = useState(false); const [isLoaded, setIsLoaded] = useState(false); + const [parallax, setParallax] = useState({ x: 0, y: 0 }); const router = useRouter(); const imgRef = useRef(null); + const cardRef = useRef(null); + + // 图片视差效果 - 优化 Safari 性能 + useEffect(() => { + let requestId: number | null = null; + let lastX = 0; + let lastY = 0; + + const handleMouseMove = (e: MouseEvent) => { + if (!cardRef.current) return; + + const rect = cardRef.current.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + // 只有当移动超过阈值时才更新视差,减少 Safari 中的重绘 + if (Math.abs(x - lastX) > 5 || Math.abs(y - lastY) > 5) { + lastX = x; + lastY = y; + + // 使用 requestAnimationFrame 优化性能 + if (requestId) cancelAnimationFrame(requestId); + requestId = requestAnimationFrame(() => { + // 计算视差偏移量 (-5 到 5 之间) + const xParallax = (x / rect.width - 0.5) * 10; + const yParallax = (y / rect.height - 0.5) * 10; + + setParallax({ x: xParallax, y: yParallax }); + }); + } + }; + + const handleMouseLeave = () => { + if (requestId) cancelAnimationFrame(requestId); + setParallax({ x: 0, y: 0 }); + }; + + if (cardRef.current) { + cardRef.current.addEventListener('mousemove', handleMouseMove); + cardRef.current.addEventListener('mouseleave', handleMouseLeave); + } + + return () => { + if (cardRef.current) { + cardRef.current.removeEventListener('mousemove', handleMouseMove); + cardRef.current.removeEventListener('mouseleave', handleMouseLeave); + } + if (requestId) cancelAnimationFrame(requestId); + }; + }, []); const handleClick = () => { router.push( @@ -60,11 +128,19 @@ const DemoCard = ({ id, title, poster, rate, type }: DemoCardProps) => { return (
{/* 海报图片区域 */} -
+
{/* 图片占位符 - 骨架屏效果 */} @@ -74,20 +150,28 @@ const DemoCard = ({ id, title, poster, rate, type }: DemoCardProps) => { alt={title} fill ref={imgRef} - className={`object-cover transition-transform duration-500 cubic-bezier(0.4,0,0.2,1) group-hover:scale-110 + className={`object-cover transition-all duration-700 cubic-bezier(0.34, 1.56, 0.64, 1) ${ isLoaded - ? 'opacity-100 scale-100' - : 'opacity-0 scale-95' + ? 'opacity-100 scale-100 blur-0' + : 'opacity-0 scale-95 blur-sm' }`} onLoadingComplete={() => setIsLoaded(true)} referrerPolicy='no-referrer' priority={false} + style={{ + transform: `scale(1.05) translate(${parallax.x}px, ${parallax.y}px)`, + transition: 'transform 0.5s cubic-bezier(0.34, 1.56, 0.64, 1)', + // 修复 Safari 中可能的渲染问题 + willChange: 'transform', + backfaceVisibility: 'hidden', + perspective: '1000px', + }} /> {/* 评分徽章 - 暗色模式优化 */} {rate && ( -
+
{rate} @@ -95,15 +179,18 @@ const DemoCard = ({ id, title, poster, rate, type }: DemoCardProps) => { )} {/* 悬浮层 - 搜索按钮 */} -
+
setHover(true)} onMouseLeave={() => setHover(false)} - className={`transition-all duration-300 cubic-bezier(0.4, 0, 0.2, 1) ${ - hover ? 'scale-110 rotate-12' : 'scale-90' + className={`transition-all duration-300 cubic-bezier(0.34, 1.56, 0.64, 1) ${ + hover ? 'scale-110' : 'scale-90' }`} > - +
@@ -113,16 +200,16 @@ const DemoCard = ({ id, title, poster, rate, type }: DemoCardProps) => { target='_blank' rel='noopener noreferrer' onClick={(e) => e.stopPropagation()} - className='absolute top-2 left-2 scale-90 group-hover:scale-100 opacity-0 group-hover:opacity-100 transition-all duration-300 cubic-bezier(0.4, 0, 0.2, 1)' + className='absolute top-2 left-2 scale-90 group-hover:scale-100 opacity-0 group-hover:opacity-100 transition-all duration-500 cubic-bezier(0.34, 1.56, 0.64, 1) group-hover:translate-y-0 translate-y-[-10px]' > -
+
{/* 信息层 - 暗色模式优化 */} - + {title} diff --git a/src/components/VideoCard.tsx b/src/components/VideoCard.tsx index 5036ede..dc0ebc5 100644 --- a/src/components/VideoCard.tsx +++ b/src/components/VideoCard.tsx @@ -1,8 +1,10 @@ +/* eslint-disable react-hooks/exhaustive-deps */ + import { Heart, Link as LinkIcon } from 'lucide-react'; import Image from 'next/image'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { deletePlayRecord, isFavorited, toggleFavorite } from '@/lib/db.client'; @@ -93,6 +95,8 @@ export default function VideoCard({ const [favorited, setFavorited] = useState(false); const [isLoaded, setIsLoaded] = useState(false); const [isDeleting, setIsDeleting] = useState(false); + const [parallax, setParallax] = useState({ x: 0, y: 0 }); + const cardRef = useRef(null); const router = useRouter(); // 检查初始收藏状态 @@ -148,6 +152,52 @@ export default function VideoCard({ } }; + // 图片视差效果 - 参考 DemoCard,优化 Safari 性能 + useEffect(() => { + let requestId: number | null = null; + let lastX = 0; + let lastY = 0; + + const handleMouseMove = (e: MouseEvent) => { + if (!cardRef.current) return; + + const rect = cardRef.current.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + // 只有在移动超过阈值时才更新,减少重绘 + if (Math.abs(x - lastX) > 5 || Math.abs(y - lastY) > 5) { + lastX = x; + lastY = y; + + if (requestId) cancelAnimationFrame(requestId); + requestId = requestAnimationFrame(() => { + const xParallax = (x / rect.width - 0.5) * 10; + const yParallax = (y / rect.height - 0.5) * 10; + setParallax({ x: xParallax, y: yParallax }); + }); + } + }; + + const handleMouseLeave = () => { + if (requestId) cancelAnimationFrame(requestId); + setParallax({ x: 0, y: 0 }); + }; + + if (cardRef.current) { + cardRef.current.addEventListener('mousemove', handleMouseMove); + cardRef.current.addEventListener('mouseleave', handleMouseLeave); + } + + return () => { + if (cardRef.current) { + cardRef.current.removeEventListener('mousemove', handleMouseMove); + cardRef.current.removeEventListener('mouseleave', handleMouseLeave); + } + if (requestId) cancelAnimationFrame(requestId); + }; + }, []); + const hideCheckCircle = from === 'favorites' || from === 'search'; const alwaysShowHeart = from !== 'favorites'; @@ -158,7 +208,8 @@ export default function VideoCard({ )}${year ? `&year=${year}` : ''}${from ? `&from=${from}` : ''}`} >
@@ -171,7 +222,7 @@ export default function VideoCard({ src={poster} alt={title} fill - className={`object-cover transition-transform duration-500 cubic-bezier(0.4,0,0.2,1) group-hover:scale-110 + className={`object-cover transition-all duration-700 cubic-bezier(0.34,1.56,0.64,1) ${ isLoaded ? 'opacity-100 scale-100' @@ -180,6 +231,13 @@ export default function VideoCard({ onLoadingComplete={() => setIsLoaded(true)} referrerPolicy='no-referrer' priority={false} + style={{ + transform: `scale(1.05) translate(${parallax.x}px, ${parallax.y}px)`, + transition: 'transform 0.5s cubic-bezier(0.34,1.56,0.64,1)', + willChange: 'transform', + backfaceVisibility: 'hidden', + perspective: '1000px', + }} /> {/* Hover 效果层 */}
{ @@ -287,14 +345,16 @@ export default function VideoCard({
)} - {/* 信息层 */} - - {title} + {/* 信息层 - 与 DemoCard 对齐的动画 */} + + + {title} + {/* 来源信息 */} {source && ( - + {source_name} From b984a078f9ed3b6858a6eaf1cbe3bc76fd14675f Mon Sep 17 00:00:00 2001 From: shinya Date: Sat, 5 Jul 2025 02:25:43 +0800 Subject: [PATCH 2/3] fix: card hover overflow --- src/components/AggregateCard.tsx | 6 +++--- src/components/DemoCard.tsx | 6 +++--- src/components/VideoCard.tsx | 8 ++++---- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/components/AggregateCard.tsx b/src/components/AggregateCard.tsx index f9bb1c7..d18248a 100644 --- a/src/components/AggregateCard.tsx +++ b/src/components/AggregateCard.tsx @@ -180,7 +180,7 @@ const AggregateCard: React.FC = ({ className='group relative w-full rounded-lg overflow-hidden bg-transparent flex flex-col cursor-pointer transition-all duration-300 ease-in-out' > {/* 封面图片 2:3 */} -
+
{/* 图片占位符 - 骨架屏效果 */} @@ -188,7 +188,7 @@ const AggregateCard: React.FC = ({ src={first.poster} alt={first.title} fill - className={`object-cover transition-all duration-700 cubic-bezier(0.34,1.56,0.64,1) + className={`object-cover transition-all duration-700 cubic-bezier(0.34,1.56,0.64,1) group-hover:scale-[1.05] ${ isLoaded ? 'opacity-100 scale-100' @@ -260,7 +260,7 @@ const AggregateCard: React.FC = ({
{/* 标题 */} - + {first.title} diff --git a/src/components/DemoCard.tsx b/src/components/DemoCard.tsx index 04229cf..b0003bc 100644 --- a/src/components/DemoCard.tsx +++ b/src/components/DemoCard.tsx @@ -134,7 +134,7 @@ const DemoCard = ({ id, title, poster, rate, type }: DemoCardProps) => { > {/* 海报图片区域 */}
{ alt={title} fill ref={imgRef} - className={`object-cover transition-all duration-700 cubic-bezier(0.34, 1.56, 0.64, 1) + className={`object-cover transition-all duration-700 cubic-bezier(0.34, 1.56, 0.64, 1) group-hover:scale-[1.05] ${ isLoaded ? 'opacity-100 scale-100 blur-0' @@ -209,7 +209,7 @@ const DemoCard = ({ id, title, poster, rate, type }: DemoCardProps) => {
{/* 信息层 - 暗色模式优化 */} - + {title} diff --git a/src/components/VideoCard.tsx b/src/components/VideoCard.tsx index dc0ebc5..f289a40 100644 --- a/src/components/VideoCard.tsx +++ b/src/components/VideoCard.tsx @@ -214,7 +214,7 @@ export default function VideoCard({ }`} > {/* 海报图片容器 */} -
+
{/* 图片占位符 - 骨架屏效果 */} @@ -222,7 +222,7 @@ export default function VideoCard({ src={poster} alt={title} fill - className={`object-cover transition-all duration-700 cubic-bezier(0.34,1.56,0.64,1) + className={`object-cover transition-all duration-700 cubic-bezier(0.34,1.56,0.64,1) group-hover:scale-[1.05] ${ isLoaded ? 'opacity-100 scale-100' @@ -346,7 +346,7 @@ export default function VideoCard({ )} {/* 信息层 - 与 DemoCard 对齐的动画 */} - + {title} @@ -354,7 +354,7 @@ export default function VideoCard({ {/* 来源信息 */} {source && ( - + {source_name} From ab73df1d6c281a761be54a9c1d5ab63bfb5717da Mon Sep 17 00:00:00 2001 From: shinya Date: Sat, 5 Jul 2025 13:22:28 +0800 Subject: [PATCH 3/3] fix: playbackrate on mobile --- src/app/play/page.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index c026b02..6397e3b 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -196,6 +196,8 @@ function PlayPageClient() { // 长按三倍速相关 const longPressTimeoutRef = useRef(null); const normalPlaybackRateRef = useRef(1); + // 标记长按是否已生效 + const longPressActiveRef = useRef(false); // 同步最新值到 refs useEffect(() => { @@ -1065,6 +1067,7 @@ function PlayPageClient() { if (playerRef.current) { normalPlaybackRateRef.current = playerRef.current.playbackRate || 1; playerRef.current.playbackRate = 3.0; + longPressActiveRef.current = true; // 记录长按已激活 displayShortcutHint('3倍速', 'play'); } }, 300); // 按压 300ms 触发 @@ -1075,8 +1078,10 @@ function PlayPageClient() { clearTimeout(longPressTimeoutRef.current); longPressTimeoutRef.current = null; } - if (playerRef.current) { + // 只有在长按激活过且当前倍速为 3.0 时才恢复,防止误触 + if (playerRef.current && longPressActiveRef.current) { playerRef.current.playbackRate = normalPlaybackRateRef.current || 1; + longPressActiveRef.current = false; } }; @@ -1870,7 +1875,7 @@ const PlaybackRateButton = ({ className='vds-radio-group' aria-label='Custom Options' value={rate.toString()} - onChange={(value) => { + onChange={(value: string) => { const player = playerRef.current; if (!player) { return; @@ -1879,7 +1884,7 @@ const PlaybackRateButton = ({ playerContainerRef.current?.focus(); }} > - {rates.reverse().map((rate) => ( + {[...rates].reverse().map((rate) => ( { xmlns='http://www.w3.org/2000/svg' >