diff --git a/src/app/page.tsx b/src/app/page.tsx index eef4e80..28640ea 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -241,34 +241,34 @@ function HomeClient() { {loading ? // 加载状态显示灰色占位数据 - Array.from({ length: 8 }).map((_, index) => ( -
-
-
-
-
+ Array.from({ length: 8 }).map((_, index) => ( +
+
+
- )) +
+
+ )) : // 显示真实数据 - hotMovies.map((movie, index) => ( -
- -
- ))} + hotMovies.map((movie, index) => ( +
+ +
+ ))} @@ -289,33 +289,33 @@ function HomeClient() { {loading ? // 加载状态显示灰色占位数据 - Array.from({ length: 8 }).map((_, index) => ( -
-
-
-
-
+ Array.from({ length: 8 }).map((_, index) => ( +
+
+
- )) +
+
+ )) : // 显示真实数据 - hotTvShows.map((show, index) => ( -
- -
- ))} + hotTvShows.map((show, index) => ( +
+ +
+ ))} @@ -336,61 +336,61 @@ function HomeClient() { {loading ? // 加载状态显示灰色占位数据 - Array.from({ length: 8 }).map((_, 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) => (
-
-
-
-
+
- )) - : // 展示当前日期的番剧 - (() => { - // 获取当前日期对应的星期 - 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) => ( -
- -
- )); - })()} + )); + })()}
@@ -411,33 +411,33 @@ function HomeClient() { {loading ? // 加载状态显示灰色占位数据 - Array.from({ length: 8 }).map((_, index) => ( -
-
-
-
-
+ Array.from({ length: 8 }).map((_, index) => ( +
+
+
- )) +
+
+ )) : // 显示真实数据 - hotVarietyShows.map((show, index) => ( -
- -
- ))} + hotVarietyShows.map((show, index) => ( +
+ +
+ ))} @@ -446,11 +446,41 @@ function HomeClient() {
{announcement && showAnnouncement && (
{ + // 如果点击的是背景区域,阻止触摸事件冒泡,防止背景滚动 + if (e.target === e.currentTarget) { + e.preventDefault(); + } + }} + onTouchMove={(e) => { + // 如果触摸的是背景区域,阻止触摸移动,防止背景滚动 + if (e.target === e.currentTarget) { + e.preventDefault(); + e.stopPropagation(); + } + }} + onTouchEnd={(e) => { + // 如果触摸的是背景区域,阻止触摸结束事件,防止背景滚动 + if (e.target === e.currentTarget) { + e.preventDefault(); + } + }} + style={{ + touchAction: 'none', // 禁用所有触摸操作 + }} > -
+
{ + // 允许公告内容区域正常滚动,阻止事件冒泡到外层 + e.stopPropagation(); + }} + style={{ + touchAction: 'auto', // 允许内容区域的正常触摸操作 + }} + >

提示 diff --git a/src/components/MobileActionSheet.tsx b/src/components/MobileActionSheet.tsx new file mode 100644 index 0000000..459fa0a --- /dev/null +++ b/src/components/MobileActionSheet.tsx @@ -0,0 +1,357 @@ +import { X } from 'lucide-react'; +import Image from 'next/image'; +import React, { useEffect, useState } from 'react'; + +interface ActionItem { + id: string; + label: string; + icon: React.ReactNode; + onClick: (e?: React.MouseEvent) => void | Promise; + color?: 'default' | 'danger' | 'primary'; + disabled?: boolean; +} + +interface MobileActionSheetProps { + isOpen: boolean; + onClose: () => void; + title: string; + actions: ActionItem[]; + poster?: string; + sources?: string[]; // 播放源信息 + isAggregate?: boolean; // 是否为聚合内容 + sourceName?: string; // 播放源名称 + currentEpisode?: number; // 当前集数 + totalEpisodes?: number; // 总集数 +} + +const MobileActionSheet: React.FC = ({ + isOpen, + onClose, + title, + actions, + poster, + sources, + isAggregate, + sourceName, + currentEpisode, + totalEpisodes, +}) => { + const [isVisible, setIsVisible] = useState(false); + const [isAnimating, setIsAnimating] = useState(false); + + // 控制动画状态 + useEffect(() => { + let animationId: number; + let timer: NodeJS.Timeout; + + if (isOpen) { + setIsVisible(true); + // 使用双重 requestAnimationFrame 确保DOM完全渲染 + animationId = requestAnimationFrame(() => { + animationId = requestAnimationFrame(() => { + setIsAnimating(true); + }); + }); + } else { + setIsAnimating(false); + // 等待动画完成后隐藏组件 + timer = setTimeout(() => { + setIsVisible(false); + }, 200); + } + + return () => { + if (animationId) { + cancelAnimationFrame(animationId); + } + if (timer) { + clearTimeout(timer); + } + }; + }, [isOpen]); + + // 阻止背景滚动 + useEffect(() => { + if (isVisible) { + // 保存当前滚动位置 + const scrollY = window.scrollY; + const scrollX = window.scrollX; + const body = document.body; + const html = document.documentElement; + + // 获取滚动条宽度 + const scrollBarWidth = window.innerWidth - html.clientWidth; + + // 保存原始样式 + const originalBodyStyle = { + position: body.style.position, + top: body.style.top, + left: body.style.left, + right: body.style.right, + width: body.style.width, + paddingRight: body.style.paddingRight, + overflow: body.style.overflow, + }; + + // 设置body样式来阻止滚动,但保持原位置 + body.style.position = 'fixed'; + body.style.top = `-${scrollY}px`; + body.style.left = `-${scrollX}px`; + body.style.right = '0'; + body.style.width = '100%'; + body.style.overflow = 'hidden'; + body.style.paddingRight = `${scrollBarWidth}px`; + + return () => { + // 恢复所有原始样式 + body.style.position = originalBodyStyle.position; + body.style.top = originalBodyStyle.top; + body.style.left = originalBodyStyle.left; + body.style.right = originalBodyStyle.right; + body.style.width = originalBodyStyle.width; + body.style.paddingRight = originalBodyStyle.paddingRight; + body.style.overflow = originalBodyStyle.overflow; + + // 使用 requestAnimationFrame 确保样式恢复后再滚动 + requestAnimationFrame(() => { + window.scrollTo(scrollX, scrollY); + }); + }; + } + }, [isVisible]); + + // ESC键关闭 + useEffect(() => { + const handleEsc = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onClose(); + } + }; + + if (isVisible) { + document.addEventListener('keydown', handleEsc); + return () => document.removeEventListener('keydown', handleEsc); + } + }, [isVisible, onClose]); + + if (!isVisible) return null; + + const getActionColor = (color: ActionItem['color']) => { + switch (color) { + case 'danger': + return 'text-red-600 dark:text-red-400'; + case 'primary': + return 'text-green-600 dark:text-green-400'; + default: + return 'text-gray-700 dark:text-gray-300'; + } + }; + + const getActionHoverColor = (color: ActionItem['color']) => { + switch (color) { + case 'danger': + return 'hover:bg-red-50/50 dark:hover:bg-red-900/10'; + case 'primary': + return 'hover:bg-green-50/50 dark:hover:bg-green-900/10'; + default: + return 'hover:bg-gray-50/50 dark:hover:bg-gray-800/20'; + } + }; + + return ( +
{ + // 阻止最外层容器的触摸移动,防止背景滚动 + e.preventDefault(); + e.stopPropagation(); + }} + style={{ + touchAction: 'none', // 禁用所有触摸操作 + }} + > + {/* 背景遮罩 */} +
{ + // 只阻止滚动,允许其他触摸事件(包括点击) + e.preventDefault(); + }} + onWheel={(e) => { + // 阻止滚轮滚动 + e.preventDefault(); + }} + style={{ + backdropFilter: 'blur(4px)', + willChange: 'opacity', + touchAction: 'none', // 禁用所有触摸操作 + }} + /> + + {/* 操作表单 */} +
{ + // 允许操作表单内部滚动,阻止事件冒泡到外层 + e.stopPropagation(); + }} + style={{ + marginBottom: 'calc(1rem + env(safe-area-inset-bottom))', + willChange: 'transform, opacity', + backfaceVisibility: 'hidden', // 避免闪烁 + transform: isAnimating + ? 'translateY(0) translateZ(0)' + : 'translateY(100%) translateZ(0)', // 组合变换保持滑入效果和硬件加速 + opacity: isAnimating ? 1 : 0, + touchAction: 'auto', // 允许操作表单内的正常触摸操作 + }} + > + {/* 头部 */} +
+
+ {poster && ( +
+ {title} +
+ )} +
+
+

+ {title} +

+ {sourceName && ( + + {sourceName} + + )} +
+

+ 选择操作 +

+
+
+ + +
+ + {/* 操作列表 */} +
+ {actions.map((action, index) => ( +
+ + + {/* 分割线 - 最后一项不显示 */} + {index < actions.length - 1 && ( +
+ )} +
+ ))} +
+ + {/* 播放源信息展示区域 */} + {isAggregate && sources && sources.length > 0 && ( +
+ {/* 标题区域 */} +
+

+ 可用播放源 +

+

+ 共 {sources.length} 个播放源 +

+
+ + {/* 播放源列表 */} +
+
+ {(() => { + // 优先显示的播放源 + const prioritySources = ['爱奇艺', '腾讯视频', '优酷', '芒果TV', '哔哩哔哩', 'Netflix', 'Disney+']; + const sortedSources = sources.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); + }); + + return sortedSources.map((source, index) => ( +
+
+ + {source} + +
+ )); + })()} +
+
+
+ )} +
+
+ ); +}; + +export default MobileActionSheet; diff --git a/src/components/UserMenu.tsx b/src/components/UserMenu.tsx index 68b2612..f122687 100644 --- a/src/components/UserMenu.tsx +++ b/src/components/UserMenu.tsx @@ -38,6 +38,29 @@ export const UserMenu: React.FC = () => { const [storageType, setStorageType] = useState('localstorage'); const [mounted, setMounted] = useState(false); + // Body 滚动锁定 - 使用 overflow 方式避免布局问题 + useEffect(() => { + if (isSettingsOpen || isChangePasswordOpen) { + const body = document.body; + const html = document.documentElement; + + // 保存原始样式 + const originalBodyOverflow = body.style.overflow; + const originalHtmlOverflow = html.style.overflow; + + // 只设置 overflow 来阻止滚动 + body.style.overflow = 'hidden'; + html.style.overflow = 'hidden'; + + return () => { + + // 恢复所有原始样式 + body.style.overflow = originalBodyOverflow; + html.style.overflow = originalHtmlOverflow; + }; + } + }, [isSettingsOpen, isChangePasswordOpen]); + // 设置相关状态 const [defaultAggregateSearch, setDefaultAggregateSearch] = useState(true); const [doubanProxyUrl, setDoubanProxyUrl] = useState(''); @@ -566,324 +589,347 @@ export const UserMenu: React.FC = () => {
{ + // 只阻止滚动,允许其他触摸事件 + e.preventDefault(); + }} + onWheel={(e) => { + // 阻止滚轮滚动 + e.preventDefault(); + }} + style={{ + touchAction: 'none', + }} /> {/* 设置面板 */} -
- {/* 标题栏 */} -
-
-

- 本地设置 -

+
+ {/* 内容容器 - 独立的滚动区域 */} +
+ {/* 标题栏 */} +
+
+

+ 本地设置 +

+ +
- -
- {/* 设置项 */} -
- {/* 豆瓣数据源选择 */} -
-
-

- 豆瓣数据代理 -

-

- 选择获取豆瓣数据的方式 -

-
-
- {/* 自定义下拉选择框 */} - - - {/* 下拉箭头 */} -
- -
- - {/* 下拉选项列表 */} - {isDoubanDropdownOpen && ( -
- {doubanDataSourceOptions.map((option) => ( - - ))} -
- )} -
- - {/* 感谢信息 */} - {getThanksInfo(doubanDataSource) && ( -
- -
- )} -
- - {/* 豆瓣代理地址设置 - 仅在选择自定义代理时显示 */} - {doubanDataSource === 'custom' && ( + {/* 设置项 */} +
+ {/* 豆瓣数据源选择 */}

- 豆瓣代理地址 + 豆瓣数据代理

- 自定义代理服务器地址 + 选择获取豆瓣数据的方式

- handleDoubanProxyUrlChange(e.target.value)} - /> -
- )} +
+ {/* 自定义下拉选择框 */} + - {/* 分割线 */} -
+ {/* 下拉箭头 */} +
+ +
- {/* 豆瓣图片代理设置 */} -
-
-

- 豆瓣图片代理 -

-

- 选择获取豆瓣图片的方式 -

-
-
- {/* 自定义下拉选择框 */} - - - {/* 下拉箭头 */} -
- + {/* 下拉选项列表 */} + {isDoubanDropdownOpen && ( +
+ {doubanDataSourceOptions.map((option) => ( + + ))} +
+ )}
- {/* 下拉选项列表 */} - {isDoubanImageProxyDropdownOpen && ( -
- {doubanImageProxyTypeOptions.map((option) => ( - - ))} + {/* 感谢信息 */} + {getThanksInfo(doubanDataSource) && ( +
+
)}
- {/* 感谢信息 */} - {getThanksInfo(doubanImageProxyType) && ( -
+ {/* 豆瓣代理地址设置 - 仅在选择自定义代理时显示 */} + {doubanDataSource === 'custom' && ( +
+
+

+ 豆瓣代理地址 +

+

+ 自定义代理服务器地址 +

+
+ handleDoubanProxyUrlChange(e.target.value)} + /> +
+ )} + + {/* 分割线 */} +
+ + {/* 豆瓣图片代理设置 */} +
+
+

+ 豆瓣图片代理 +

+

+ 选择获取豆瓣图片的方式 +

+
+
+ {/* 自定义下拉选择框 */} + + {/* 下拉箭头 */} +
+ +
+ + {/* 下拉选项列表 */} + {isDoubanImageProxyDropdownOpen && ( +
+ {doubanImageProxyTypeOptions.map((option) => ( + + ))} +
+ )} +
+ + {/* 感谢信息 */} + {getThanksInfo(doubanImageProxyType) && ( +
+ +
+ )} +
+ + {/* 豆瓣图片代理地址设置 - 仅在选择自定义代理时显示 */} + {doubanImageProxyType === 'custom' && ( +
+
+

+ 豆瓣图片代理地址 +

+

+ 自定义图片代理服务器地址 +

+
+ + handleDoubanImageProxyUrlChange(e.target.value) + } + />
)} -
- {/* 豆瓣图片代理地址设置 - 仅在选择自定义代理时显示 */} - {doubanImageProxyType === 'custom' && ( -
+ {/* 分割线 */} +
+ + {/* 默认聚合搜索结果 */} +

- 豆瓣图片代理地址 + 默认聚合搜索结果

- 自定义图片代理服务器地址 + 搜索时默认按标题和年份聚合显示结果

- - handleDoubanImageProxyUrlChange(e.target.value) - } - /> +
- )} - {/* 分割线 */} -
- - {/* 默认聚合搜索结果 */} -
-
-

- 默认聚合搜索结果 -

-

- 搜索时默认按标题和年份聚合显示结果 -

-
-
@@ -896,87 +942,113 @@ export const UserMenu: React.FC = () => {
{ + // 只阻止滚动,允许其他触摸事件 + e.preventDefault(); + }} + onWheel={(e) => { + // 阻止滚轮滚动 + e.preventDefault(); + }} + style={{ + touchAction: 'none', + }} /> {/* 修改密码面板 */} -
- {/* 标题栏 */} -
-

- 修改密码 -

- -
- - {/* 表单 */} -
- {/* 新密码输入 */} -
- - setNewPassword(e.target.value)} - disabled={passwordLoading} - /> +
+ {/* 内容容器 - 独立的滚动区域 */} +
{ + // 阻止事件冒泡到遮罩层,但允许内部滚动 + e.stopPropagation(); + }} + style={{ + touchAction: 'auto', // 允许所有触摸操作 + }} + > + {/* 标题栏 */} +
+

+ 修改密码 +

+
- {/* 确认密码输入 */} -
- - setConfirmPassword(e.target.value)} - disabled={passwordLoading} - /> -
- - {/* 错误信息 */} - {passwordError && ( -
- {passwordError} + {/* 表单 */} +
+ {/* 新密码输入 */} +
+ + setNewPassword(e.target.value)} + disabled={passwordLoading} + />
- )} -
- {/* 操作按钮 */} -
- - -
+ {/* 确认密码输入 */} +
+ + setConfirmPassword(e.target.value)} + disabled={passwordLoading} + /> +
- {/* 底部说明 */} -
-

- 修改密码后需要重新登录 -

+ {/* 错误信息 */} + {passwordError && ( +
+ {passwordError} +
+ )} +
+ + {/* 操作按钮 */} +
+ + +
+ + {/* 底部说明 */} +
+

+ 修改密码后需要重新登录 +

+
diff --git a/src/components/VersionPanel.tsx b/src/components/VersionPanel.tsx index b646753..4d71621 100644 --- a/src/components/VersionPanel.tsx +++ b/src/components/VersionPanel.tsx @@ -17,7 +17,7 @@ import { createPortal } from 'react-dom'; import { changelog, ChangelogEntry } from '@/lib/changelog'; import { CURRENT_VERSION } from '@/lib/version'; -import { compareVersions,UpdateStatus } from '@/lib/version_check'; +import { compareVersions, UpdateStatus } from '@/lib/version_check'; interface VersionPanelProps { isOpen: boolean; @@ -268,10 +268,35 @@ export const VersionPanel: React.FC = ({
{ + // 阻止触摸事件冒泡,防止背景滚动 + e.preventDefault(); + }} + onTouchMove={(e) => { + // 阻止触摸移动,防止背景滚动 + e.preventDefault(); + e.stopPropagation(); + }} + onTouchEnd={(e) => { + // 阻止触摸结束事件,防止背景滚动 + e.preventDefault(); + }} + style={{ + touchAction: 'none', // 禁用所有触摸操作 + }} /> {/* 版本面板 */} -
+
{ + // 允许版本面板内部滚动,阻止事件冒泡到外层 + e.stopPropagation(); + }} + style={{ + touchAction: 'auto', // 允许面板内的正常触摸操作 + }} + > {/* 标题栏 */}
diff --git a/src/components/VideoCard.tsx b/src/components/VideoCard.tsx index 47474d3..3cf4f87 100644 --- a/src/components/VideoCard.tsx +++ b/src/components/VideoCard.tsx @@ -1,4 +1,4 @@ -/* eslint-disable @typescript-eslint/no-explicit-any,react-hooks/exhaustive-deps */ +/* eslint-disable @typescript-eslint/no-explicit-any,react-hooks/exhaustive-deps,@typescript-eslint/no-empty-function */ import { Heart, Link, PlayCircleIcon, Trash2 } from 'lucide-react'; import Image from 'next/image'; @@ -21,9 +21,12 @@ import { saveFavorite, subscribeToDataUpdates, } from '@/lib/db.client'; +import { isMobileDevice, isTouchDevice } from '@/lib/device'; import { processImageUrl } from '@/lib/utils'; +import { useLongPress } from '@/hooks/useLongPress'; import { ImagePlaceholder } from '@/components/ImagePlaceholder'; +import MobileActionSheet from '@/components/MobileActionSheet'; export interface VideoCardProps { id?: string; @@ -77,6 +80,10 @@ const VideoCard = forwardRef(function VideoCard const router = useRouter(); const [favorited, setFavorited] = useState(false); const [isLoading, setIsLoading] = useState(false); + const [isMobile, setIsMobile] = useState(false); + const [isTouch, setIsTouch] = useState(false); + const [showMobileActions, setShowMobileActions] = useState(false); + const [searchFavorited, setSearchFavorited] = useState(null); // 搜索结果的收藏状态 // 可外部修改的可控字段 const [dynamicEpisodes, setDynamicEpisodes] = useState( @@ -94,6 +101,12 @@ const VideoCard = forwardRef(function VideoCard setDynamicSourceNames(source_names); }, [source_names]); + // 检测设备类型 + useEffect(() => { + setIsMobile(isMobileDevice()); + setIsTouch(isTouchDevice()); + }, []); + useImperativeHandle(ref, () => ({ setEpisodes: (eps?: number) => setDynamicEpisodes(eps), setSourceNames: (names?: string[]) => setDynamicSourceNames(names), @@ -144,12 +157,20 @@ const VideoCard = forwardRef(function VideoCard async (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); - if (from === 'douban' || from === 'search' || !actualSource || !actualId) return; + if (from === 'douban' || !actualSource || !actualId) return; + try { - if (favorited) { + // 确定当前收藏状态 + const currentFavorited = from === 'search' ? searchFavorited : favorited; + + if (currentFavorited) { // 如果已收藏,删除收藏 await deleteFavorite(actualSource, actualId); - setFavorited(false); + if (from === 'search') { + setSearchFavorited(false); + } else { + setFavorited(false); + } } else { // 如果未收藏,添加收藏 await saveFavorite(actualSource, actualId, { @@ -160,7 +181,11 @@ const VideoCard = forwardRef(function VideoCard total_episodes: actualEpisodes ?? 1, save_time: Date.now(), }); - setFavorited(true); + if (from === 'search') { + setSearchFavorited(true); + } else { + setFavorited(true); + } } } catch (err) { throw new Error('切换收藏状态失败'); @@ -176,6 +201,7 @@ const VideoCard = forwardRef(function VideoCard actualPoster, actualEpisodes, favorited, + searchFavorited, ] ); @@ -221,6 +247,38 @@ const VideoCard = forwardRef(function VideoCard 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 (isMobile && !showMobileActions) { // 防止重复触发 + // 立即显示菜单,避免等待数据加载导致动画卡顿 + setShowMobileActions(true); + + // 异步检查收藏状态,不阻塞菜单显示 + if (from === 'search' && !isAggregate && actualSource && actualId && searchFavorited === null) { + checkSearchFavoriteStatus(); + } + } + }, [isMobile, showMobileActions, from, isAggregate, actualSource, actualId, searchFavorited, checkSearchFavoriteStatus]); + + // 长按手势hook + const longPressProps = useLongPress({ + onLongPress: handleLongPress, + onClick: handleClick, // 保持点击播放功能 + longPressDelay: 500, + }); + const config = useMemo(() => { const configs = { playrecord: { @@ -247,7 +305,7 @@ const VideoCard = forwardRef(function VideoCard showSourceName: true, showProgress: false, showPlayButton: true, - showHeart: false, + showHeart: true, // 移动端菜单中需要显示收藏选项 showCheckCircle: false, showDoubanLink: false, showRating: false, @@ -267,216 +325,394 @@ const VideoCard = forwardRef(function VideoCard return configs[from] || configs.search; }, [from, isAggregate, douban_id, rate]); + // 移动端操作菜单配置 + const mobileActions = useMemo(() => { + const actions = []; + + // 播放操作 + if (config.showPlayButton) { + actions.push({ + id: 'play', + label: '播放', + icon: , + onClick: handleClick, + color: 'primary' as const, + }); + } + + // 聚合源信息 - 直接在菜单中展示,不需要单独的操作项 + + // 收藏/取消收藏操作 + if (config.showHeart && from !== 'douban' && actualSource && actualId) { + const currentFavorited = from === 'search' ? searchFavorited : favorited; + + if (from === 'search') { + // 搜索结果:根据加载状态显示不同的选项 + if (searchFavorited !== null) { + // 已加载完成,显示实际的收藏状态 + actions.push({ + id: 'favorite', + label: currentFavorited ? '取消收藏' : '添加收藏', + icon: , + 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: , + onClick: () => { + const mockEvent = { + preventDefault: () => { }, + stopPropagation: () => { }, + } as React.MouseEvent; + handleToggleFavorite(mockEvent); + }, + color: currentFavorited ? ('danger' as const) : ('default' 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, + }); + } + + return actions; + }, [ + config, + from, + actualSource, + actualId, + favorited, + searchFavorited, + actualDoubanId, + isBangumi, + isAggregate, + dynamicSourceNames, + handleClick, + handleToggleFavorite, + handleDeleteRecord, + ]); + return ( -
- {/* 海报容器 */} -
- {/* 骨架屏 */} - {!isLoading && } - {/* 图片 */} - {actualTitle} setIsLoading(true)} - onError={(e) => { - // 图片加载失败时的重试机制 - const img = e.target as HTMLImageElement; - if (!img.dataset.retried) { - img.dataset.retried = 'true'; - setTimeout(() => { - img.src = processImageUrl(actualPoster); - }, 2000); - } - }} - /> + <> +
{ + // 阻止右键菜单和长按上下文菜单 + e.preventDefault(); + e.stopPropagation(); + return false; + }} - {/* 悬浮遮罩 */} -
+ onDragStart={(e) => { + // 阻止拖拽 + e.preventDefault(); + return false; + }} + > + {/* 海报容器 */} +
+ {/* 骨架屏 */} + {!isLoading && } + {/* 图片 */} + {actualTitle} setIsLoading(true)} + onError={(e) => { + // 图片加载失败时的重试机制 + const img = e.target as HTMLImageElement; + if (!img.dataset.retried) { + img.dataset.retried = 'true'; + setTimeout(() => { + img.src = processImageUrl(actualPoster); + }, 2000); + } + }} + style={{ + // 禁用图片的默认长按效果 + WebkitUserSelect: 'none', + userSelect: 'none', + WebkitTouchCallout: 'none', + pointerEvents: 'none', // 图片不响应任何指针事件 + } as React.CSSProperties} + onContextMenu={(e) => { + e.preventDefault(); + return false; + }} + onDragStart={(e) => { + e.preventDefault(); + return false; + }} + /> - {/* 播放按钮 */} - {config.showPlayButton && ( -
- + + {/* 播放按钮 - Touch设备隐藏,非Touch设备显示 */} + {config.showPlayButton && !isTouch && ( +
+ +
+ )} + + + + {/* 操作按钮 - Touch设备隐藏,非Touch设备显示 */} + {(config.showHeart || config.showCheckCircle) && !isTouch && ( +
+ {config.showCheckCircle && ( + + )} + {config.showHeart && ( + + )} +
+ )} + + {/* 年份徽章 */} + {config.showYear && actualYear && actualYear !== 'unknown' && actualYear.trim() !== '' && ( +
+ {actualYear} +
+ )} + + {/* 徽章 */} + {config.showRating && rate && ( +
+ {rate} +
+ )} + + {actualEpisodes && actualEpisodes > 1 && ( +
+ {currentEpisode + ? `${currentEpisode}/${actualEpisodes}` + : actualEpisodes} +
+ )} + + {/* 豆瓣链接 - Touch设备隐藏,非Touch设备显示 */} + {config.showDoubanLink && actualDoubanId && actualDoubanId !== 0 && !isTouch && ( + e.stopPropagation()} + className='absolute top-2 left-2 opacity-0 -translate-x-2 transition-all duration-300 ease-in-out delay-100 group-hover:opacity-100 group-hover:translate-x-0' + > +
+ +
+
+ )} + + {/* 聚合播放源指示器 */} + {isAggregate && dynamicSourceNames && dynamicSourceNames.length > 0 && (() => { + const uniqueSources = Array.from(new Set(dynamicSourceNames)); + const sourceCount = uniqueSources.length; + + return ( +
+
+
+ {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 ( +
+
+ {/* 单列布局 */} +
+ {displaySources.map((sourceName, index) => ( +
+
+ + {sourceName} + +
+ ))} +
+ + {/* 显示更多提示 */} + {hasMore && ( +
+
+ +{remainingCount} 播放源 +
+
+ )} + + {/* 小箭头 */} +
+
+
+ ); + })()} +
+
+ ); + })()} +
+ + {/* 进度条 */} + {config.showProgress && progress !== undefined && ( +
+
)} - {/* 操作按钮 */} - {(config.showHeart || config.showCheckCircle) && ( -
- {config.showCheckCircle && ( - - )} - {config.showHeart && ( - - )} -
- )} - - {/* 年份徽章 */} - {config.showYear && actualYear && actualYear !== 'unknown' && actualYear.trim() !== '' && ( -
- {actualYear} -
- )} - - {/* 徽章 */} - {config.showRating && rate && ( -
- {rate} -
- )} - - {actualEpisodes && actualEpisodes > 1 && ( -
- {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 group-hover:opacity-100 group-hover:translate-x-0' - > -
- + {/* 标题与来源 */} +
+
+ + {actualTitle} + + {/* 自定义 tooltip */} +
+ {actualTitle} +
-
- )} - - {/* 聚合播放源指示器 */} - {isAggregate && dynamicSourceNames && dynamicSourceNames.length > 0 && (() => { - const uniqueSources = Array.from(new Set(dynamicSourceNames)); - const sourceCount = uniqueSources.length; - - return ( -
-
-
- {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 ( -
-
- {/* 单列布局 */} -
- {displaySources.map((sourceName, index) => ( -
-
- - {sourceName} - -
- ))} -
- - {/* 显示更多提示 */} - {hasMore && ( -
-
- +{remainingCount} 播放源 -
-
- )} - - {/* 小箭头 */} -
-
-
- ); - })()} -
-
- ); - })()} +
+ {config.showSourceName && source_name && ( + + + {source_name} + + + )} +
- {/* 进度条 */} - {config.showProgress && progress !== undefined && ( -
-
-
+ {/* 移动端操作菜单 */} + {isMobile && ( + 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} + /> )} - {/* 标题与来源 */} -
-
- - {actualTitle} - - {/* 自定义 tooltip */} -
- {actualTitle} -
-
-
- {config.showSourceName && source_name && ( - - - {source_name} - - - )} -
-
+ + ); } diff --git a/src/hooks/useLongPress.ts b/src/hooks/useLongPress.ts new file mode 100644 index 0000000..1d0bce5 --- /dev/null +++ b/src/hooks/useLongPress.ts @@ -0,0 +1,153 @@ +import { useCallback, useRef } from 'react'; + +interface UseLongPressOptions { + onLongPress: () => void; + onClick?: () => void; + longPressDelay?: number; + moveThreshold?: number; +} + +interface TouchPosition { + x: number; + y: number; +} + +export const useLongPress = ({ + onLongPress, + onClick, + longPressDelay = 500, + moveThreshold = 10, +}: UseLongPressOptions) => { + const isLongPress = useRef(false); + const pressTimer = useRef(null); + const startPosition = useRef(null); + const isActive = useRef(false); // 防止重复触发 + + const clearTimer = useCallback(() => { + if (pressTimer.current) { + clearTimeout(pressTimer.current); + pressTimer.current = null; + } + }, []); + + const handleStart = useCallback( + (clientX: number, clientY: number) => { + // 如果已经有活跃的手势,忽略新的开始 + if (isActive.current) return; + + isActive.current = true; + isLongPress.current = false; + startPosition.current = { x: clientX, y: clientY }; + + pressTimer.current = setTimeout(() => { + // 再次检查是否仍然活跃 + if (!isActive.current) return; + + isLongPress.current = true; + + // 添加触觉反馈(如果支持) + if (navigator.vibrate) { + navigator.vibrate(50); + } + + // 触发长按事件 + onLongPress(); + }, longPressDelay); + }, + [onLongPress, longPressDelay] + ); + + const handleMove = useCallback( + (clientX: number, clientY: number) => { + if (!startPosition.current || !isActive.current) return; + + const distance = Math.sqrt( + Math.pow(clientX - startPosition.current.x, 2) + + Math.pow(clientY - startPosition.current.y, 2) + ); + + // 如果移动距离超过阈值,取消长按 + if (distance > moveThreshold) { + clearTimer(); + isActive.current = false; + } + }, + [clearTimer, moveThreshold] + ); + + const handleEnd = useCallback(() => { + clearTimer(); + + // 如果不是长按且手势仍然活跃,则触发点击事件 + if (!isLongPress.current && onClick && isActive.current) { + onClick(); + } + + // 重置所有状态 + isLongPress.current = false; + startPosition.current = null; + isActive.current = false; + }, [clearTimer, onClick]); + + // 触摸事件处理器 + const onTouchStart = useCallback( + (e: React.TouchEvent) => { + // 阻止默认的长按行为,但不阻止触摸开始事件 + const touch = e.touches[0]; + handleStart(touch.clientX, touch.clientY); + }, + [handleStart] + ); + + const onTouchMove = useCallback( + (e: React.TouchEvent) => { + const touch = e.touches[0]; + handleMove(touch.clientX, touch.clientY); + }, + [handleMove] + ); + + const onTouchEnd = useCallback( + (e: React.TouchEvent) => { + // 始终阻止默认行为,避免任何系统长按菜单 + e.preventDefault(); + e.stopPropagation(); + handleEnd(); + }, + [handleEnd] + ); + + // 鼠标事件处理器(用于桌面端测试) + const onMouseDown = useCallback( + (e: React.MouseEvent) => { + handleStart(e.clientX, e.clientY); + }, + [handleStart] + ); + + const onMouseMove = useCallback( + (e: React.MouseEvent) => { + handleMove(e.clientX, e.clientY); + }, + [handleMove] + ); + + const onMouseUp = useCallback(() => { + handleEnd(); + }, [handleEnd]); + + const onMouseLeave = useCallback(() => { + clearTimer(); + isActive.current = false; + }, [clearTimer]); + + return { + onTouchStart, + onTouchMove, + onTouchEnd, + onMouseDown, + onMouseMove, + onMouseUp, + onMouseLeave, + }; +}; diff --git a/src/lib/device.ts b/src/lib/device.ts new file mode 100644 index 0000000..5fb830f --- /dev/null +++ b/src/lib/device.ts @@ -0,0 +1,43 @@ +/** + * 设备检测工具函数 + */ + +// 检测是否为移动设备 +export const isMobileDevice = (): boolean => { + if (typeof window === 'undefined') return false; + + // 检测触摸屏支持 + const hasTouchScreen = 'ontouchstart' in window || navigator.maxTouchPoints > 0; + + // 检测用户代理 + const userAgent = navigator.userAgent.toLowerCase(); + const mobileKeywords = ['mobile', 'android', 'iphone', 'ipad', 'tablet']; + const isMobileUA = mobileKeywords.some(keyword => userAgent.includes(keyword)); + + // 检测屏幕尺寸(小于768px认为是移动设备) + const isSmallScreen = window.innerWidth < 768; + + return hasTouchScreen && (isMobileUA || isSmallScreen); +}; + +// 检测是否为触摸设备 +export const isTouchDevice = (): boolean => { + if (typeof window === 'undefined') return false; + return 'ontouchstart' in window || navigator.maxTouchPoints > 0; +}; + +// 获取设备类型 +export const getDeviceType = (): 'mobile' | 'tablet' | 'desktop' => { + if (typeof window === 'undefined') return 'desktop'; + + const width = window.innerWidth; + const userAgent = navigator.userAgent.toLowerCase(); + + if (width < 768 || userAgent.includes('mobile') || userAgent.includes('iphone')) { + return 'mobile'; + } else if (width < 1024 || userAgent.includes('tablet') || userAgent.includes('ipad')) { + return 'tablet'; + } else { + return 'desktop'; + } +};