From f4a4768c169dda4b5de459307b2e8ba5a3635416 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Mon, 26 Jan 2026 17:40:09 +0800 Subject: [PATCH] =?UTF-8?q?=E7=9B=B4=E9=93=BE=E6=92=AD=E6=94=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/page.tsx | 87 ++++- src/app/play/page.tsx | 596 +++++++++++++++++------------ src/components/EpisodeSelector.tsx | 10 +- src/components/VideoCard.tsx | 78 ++-- 4 files changed, 478 insertions(+), 293 deletions(-) diff --git a/src/app/page.tsx b/src/app/page.tsx index 8c5b233..bcc4aa9 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -2,8 +2,9 @@ 'use client'; -import { Bot, ChevronRight, ListVideo } from 'lucide-react'; +import { Bot, ChevronRight, Link as LinkIcon, ListVideo } from 'lucide-react'; import Link from 'next/link'; +import { useRouter } from 'next/navigation'; import { Suspense, useEffect, useState } from 'react'; import { @@ -13,7 +14,7 @@ import { import { getDoubanCategories } from '@/lib/douban.client'; import { getTMDBImageUrl, TMDBItem } from '@/lib/tmdb.client'; import { DoubanItem } from '@/lib/types'; -import { processImageUrl } from '@/lib/utils'; +import { base58Encode, processImageUrl } from '@/lib/utils'; import AIChatPanel from '@/components/AIChatPanel'; import BannerCarousel from '@/components/BannerCarousel'; @@ -45,6 +46,7 @@ function HomeClient() { >([]); const [loading, setLoading] = useState(true); const { announcement } = useSite(); + const router = useRouter(); // 首页模块配置状态 const [homeModules, setHomeModules] = useState([ @@ -64,6 +66,23 @@ function HomeClient() { const [aiEnabled, setAiEnabled] = useState(false); const [aiDefaultMessageNoVideo, setAiDefaultMessageNoVideo] = useState('你好!我是MoonTVPlus的AI影视助手。想看什么电影或剧集?需要推荐吗?'); const [sourceSearchEnabled, setSourceSearchEnabled] = useState(true); + const [showDirectPlayDialog, setShowDirectPlayDialog] = useState(false); + const [directPlayUrl, setDirectPlayUrl] = useState(''); + + const handleDirectPlay = () => { + setDirectPlayUrl(''); + setShowDirectPlayDialog(true); + }; + + const submitDirectPlay = () => { + const trimmed = directPlayUrl.trim(); + if (!trimmed) return; + const encoded = base58Encode(trimmed); + if (!encoded) return; + setShowDirectPlayDialog(false); + setDirectPlayUrl(''); + router.push(`/play?source=directplay&id=${encodeURIComponent(encoded)}`); + }; const loadHomeLayoutSettings = () => { if (typeof window === 'undefined') return; @@ -566,6 +585,14 @@ function HomeClient() { <> {/* 源站寻片和AI问片入口 */}
+ + {/* 源站寻片入口 */} {sourceSearchEnabled && ( @@ -635,6 +662,62 @@ function HomeClient() {
)} + + {showDirectPlayDialog && ( +
setShowDirectPlayDialog(false)} + > +
event.stopPropagation()} + > +
+

+ 直链播放 +

+ +
+
+
+ 请输入可直接播放的视频链接。 +
+ setDirectPlayUrl(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + submitDirectPlay(); + } + }} + placeholder='https://example.com/video.m3u8' + className='w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500' + /> +
+ + +
+
+
+
+ )} ); } diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index f2e1055..d081a7d 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -48,7 +48,7 @@ import { import { getDoubanDetail } from '@/lib/douban.client'; import { getTMDBImageUrl } from '@/lib/tmdb.search'; import { DanmakuFilterConfig, EpisodeFilterConfig,SearchResult } from '@/lib/types'; -import { getVideoResolutionFromM3u8, processImageUrl } from '@/lib/utils'; +import { base58Decode, getVideoResolutionFromM3u8, processImageUrl } from '@/lib/utils'; import { useEnableComments } from '@/hooks/useEnableComments'; import { usePlaySync } from '@/hooks/usePlaySync'; @@ -488,6 +488,7 @@ function PlayPageClient() { const [currentSource, setCurrentSource] = useState(searchParams.get('source') || ''); const [currentId, setCurrentId] = useState(searchParams.get('id') || ''); const [fileName] = useState(searchParams.get('fileName') || ''); // 小雅源:用户点击的文件名 + const isDirectPlay = currentSource === 'directplay'; // 解析 source 参数以获取 embyKey(仅用于 API 调用) const parseSourceForApi = (source: string): { source: string; embyKey?: string } => { @@ -601,6 +602,10 @@ function PlayPageClient() { return; } + if (isDirectPlay) { + return; + } + // 检查是否禁用了自动装填弹幕 const disableAutoLoad = localStorage.getItem('disableAutoLoadDanmaku') === 'true'; if (disableAutoLoad) { @@ -938,11 +943,19 @@ function PlayPageClient() { }; loadDanmakuForCurrentEpisode(); - }, [currentEpisodeIndex, videoTitle, loading]); + }, [currentEpisodeIndex, videoTitle, loading, isDirectPlay]); // 获取豆瓣评分数据 useEffect(() => { const fetchDoubanRating = async () => { + if (isDirectPlay) { + setDoubanRating(null); + setDoubanCardSubtitle(''); + setDoubanAka([]); + setDoubanYear(''); + return; + } + if (!videoDoubanId || videoDoubanId === 0) { setDoubanRating(null); setDoubanCardSubtitle(''); @@ -994,11 +1007,16 @@ function PlayPageClient() { }; fetchDoubanRating(); - }, [videoDoubanId]); + }, [videoDoubanId, isDirectPlay]); // 获取TMDB背景图 useEffect(() => { const fetchTMDBBackdrop = async () => { + if (isDirectPlay) { + setTmdbBackdrop(null); + return; + } + // 检查是否禁用背景图 if (typeof window !== 'undefined') { const disabled = localStorage.getItem('tmdb_backdrop_disabled'); @@ -1146,7 +1164,7 @@ function PlayPageClient() { }; fetchTMDBBackdrop(); - }, [videoTitle, videoDoubanId]); + }, [videoTitle, videoDoubanId, isDirectPlay]); // 视频播放地址 @@ -1168,6 +1186,14 @@ function PlayPageClient() { // 总集数 const totalEpisodes = detail?.episodes?.length || 0; + const directEpisodeLabel = detail?.episodes_titles?.[currentEpisodeIndex] || '直链'; + const shouldShowEpisodeLabel = totalEpisodes > 1 || isDirectPlay; + const episodeLabel = isDirectPlay + ? directEpisodeLabel + : detail?.episodes_titles?.[currentEpisodeIndex] || `第 ${currentEpisodeIndex + 1} 集`; + const playerEpisodeLabel = isDirectPlay + ? directEpisodeLabel + : `第${currentEpisodeIndex + 1}集`; // 用于记录是否需要在播放器 ready 后跳转到指定进度 const resumeTimeRef = useRef(null); @@ -2808,6 +2834,73 @@ function PlayPageClient() { }; const initAll = async () => { + if (currentSource === 'directplay') { + if (!currentId) { + setError('缺少直链地址'); + setLoading(false); + return; + } + + setLoading(true); + setLoadingStage('fetching'); + setLoadingMessage('🎬 正在准备直链播放...'); + + let directUrl = ''; + try { + directUrl = base58Decode(currentId); + } catch (decodeError) { + console.error('直链地址解析失败:', decodeError); + setError('直链地址解析失败'); + setLoading(false); + return; + } + + const directDetail: SearchResult = { + id: currentId, + title: '直链播放', + poster: '', + episodes: [directUrl], + episodes_titles: ['直链'], + source: 'directplay', + source_name: '直链', + class: '', + year: '', + desc: '', + type_name: '', + douban_id: 0, + }; + + setNeedPrefer(false); + setCurrentSource('directplay'); + setCurrentId(currentId); + setVideoTitle('直链播放'); + setVideoYear(''); + setVideoCover(''); + setVideoDoubanId(0); + setCorrectedDesc(''); + setDetail(directDetail); + setSourceProxyMode(false); + setAvailableSources([directDetail]); + setCurrentEpisodeIndex(0); + setSourceSearchError(null); + setSourceSearchLoading(false); + setBackgroundSourcesLoading(false); + + const newUrl = new URL(window.location.href); + newUrl.searchParams.set('source', 'directplay'); + newUrl.searchParams.set('id', currentId); + newUrl.searchParams.delete('prefer'); + newUrl.searchParams.delete('fileName'); + window.history.replaceState({}, '', newUrl.toString()); + + setLoadingStage('ready'); + setLoadingMessage('✨ 准备就绪,即将开始播放...'); + setTimeout(() => { + setLoading(false); + }, 500); + return; + } + if (!currentSource && !currentId && !videoTitle && !searchTitle) { setError('缺少必要参数'); setLoading(false); @@ -3698,6 +3791,7 @@ function PlayPageClient() { // 预加载下一集弹幕(完全复制 loadDanmakuForCurrentEpisode 的逻辑) const preloadNextEpisodeDanmaku = async () => { try { + if (isDirectPlay) return; const disableAutoLoad = localStorage.getItem('disableAutoLoadDanmaku') === 'true'; if (disableAutoLoad) return; @@ -4043,6 +4137,7 @@ function PlayPageClient() { // 自动搜索并加载弹幕 const autoSearchDanmaku = async () => { + if (isDirectPlay) return; const disableAutoLoad = localStorage.getItem('disableAutoLoadDanmaku') === 'true'; if (disableAutoLoad) return; @@ -4616,9 +4711,7 @@ function PlayPageClient() { // 非WebKit浏览器且播放器已存在,使用switch方法切换 if (!isWebkit && artPlayerRef.current) { artPlayerRef.current.switch = videoUrl; - artPlayerRef.current.title = `${videoTitle} - 第${ - currentEpisodeIndex + 1 - }集`; + artPlayerRef.current.title = `${videoTitle} - ${playerEpisodeLabel}`; artPlayerRef.current.poster = videoCover; if (artPlayerRef.current?.video) { ensureVideoSource( @@ -6993,12 +7086,9 @@ function PlayPageClient() {

{videoTitle || '影片标题'} - {totalEpisodes > 1 && ( + {shouldShowEpisodeLabel && ( - {` > ${ - detail?.episodes_titles?.[currentEpisodeIndex] || - `第 ${currentEpisodeIndex + 1} 集` - }`} + {` > ${episodeLabel}`} )} @@ -7568,254 +7658,258 @@ function PlayPageClient() { - {/* 详情展示 */} -
- {/* 文字区 */} -
-
- {/* 标题 */} -

- 0 ? 'relative group cursor-help' : ''}> - {videoTitle || '影片标题'} - {/* aka 悬浮提示 */} - {doubanAka.length > 0 && ( -
-
又名:
- {doubanAka.map((name, index) => ( -
- {name} + {!isDirectPlay && ( + <> + {/* 详情展示 */} +
+ {/* 文字区 */} +
+
+ {/* 标题 */} +

+ 0 ? 'relative group cursor-help' : ''}> + {videoTitle || '影片标题'} + {/* aka 悬浮提示 */} + {doubanAka.length > 0 && ( +
+
又名:
+ {doubanAka.map((name, index) => ( +
+ {name} +
+ ))} +
- ))} -
-

- )} - - - {/* 网盘搜索按钮 */} - - {/* AI问片按钮 */} - {aiEnabled && detail && ( - - )} - {/* 纠错按钮 - 仅小雅源显示 */} - {detail && detail.source === 'xiaoya' && ( - - )} - {/* 豆瓣评分显示 */} - {doubanRating && doubanRating.value > 0 && ( -
- {/* 星级显示 */} -
- {[1, 2, 3, 4, 5].map((star) => { - const starValue = doubanRating.value / 2; // 转换为5星制 - const isFullStar = star <= Math.floor(starValue); - const isHalfStar = !isFullStar && star <= Math.ceil(starValue) && starValue % 1 >= 0.25; - - return ( -
- {isFullStar ? ( - // 全星 - - - - ) : isHalfStar ? ( - // 半星 - <> - {/* 空星背景 */} - - - - {/* 半星遮罩 */} - - - - - ) : ( - // 空星 - - - - )} -
- ); - })} -
- {/* 评分数值 */} - - {doubanRating.value.toFixed(1)} + )} - {/* 评分人数 */} - - ({doubanRating.count.toLocaleString()}人评价) - -
- )} -

- - {/* 关键信息行 */} -
- {detail?.class && ( - - {detail.class} - - )} - {/* 优先使用 doubanYear,如果没有则使用 detail.year 或 videoYear */} - {(doubanYear || detail?.year || videoYear) && ( - {doubanYear || detail?.year || videoYear} - )} - {detail?.source_name && ( - - {detail.source_name} - - )} - {detail?.type_name && {detail.type_name}} -
- {/* 剧情简介 */} - {(doubanCardSubtitle || correctedDesc || detail?.desc) && ( -
- {/* card_subtitle 在前,desc 在后 */} - {doubanCardSubtitle && ( -
- {doubanCardSubtitle} -
- )} - {correctedDesc || detail?.desc} -
- )} -
-
- - {/* 封面展示 */} -
-
-
- {videoCover ? ( - <> - {videoTitle} - - {/* 豆瓣链接按钮 */} - {videoDoubanId !== 0 && ( - { + e.stopPropagation(); + handleToggleFavorite(); + }} + className='flex-shrink-0 hover:opacity-80 transition-opacity' + > + + + {/* 网盘搜索按钮 */} + + {/* AI问片按钮 */} + {aiEnabled && detail && ( + )} - - ) : ( - - 封面图片 - - )} + {/* 纠错按钮 - 仅小雅源显示 */} + {detail && detail.source === 'xiaoya' && ( + + )} + {/* 豆瓣评分显示 */} + {doubanRating && doubanRating.value > 0 && ( +
+ {/* 星级显示 */} +
+ {[1, 2, 3, 4, 5].map((star) => { + const starValue = doubanRating.value / 2; // 转换为5星制 + const isFullStar = star <= Math.floor(starValue); + const isHalfStar = !isFullStar && star <= Math.ceil(starValue) && starValue % 1 >= 0.25; + + return ( +
+ {isFullStar ? ( + // 全星 + + + + ) : isHalfStar ? ( + // 半星 + <> + {/* 空星背景 */} + + + + {/* 半星遮罩 */} + + + + + ) : ( + // 空星 + + + + )} +
+ ); + })} +
+ {/* 评分数值 */} + + {doubanRating.value.toFixed(1)} + + {/* 评分人数 */} + + ({doubanRating.count.toLocaleString()}人评价) + +
+ )} +

+ + {/* 关键信息行 */} +
+ {detail?.class && ( + + {detail.class} + + )} + {/* 优先使用 doubanYear,如果没有则使用 detail.year 或 videoYear */} + {(doubanYear || detail?.year || videoYear) && ( + {doubanYear || detail?.year || videoYear} + )} + {detail?.source_name && ( + + {detail.source_name} + + )} + {detail?.type_name && {detail.type_name}} +
+ {/* 剧情简介 */} + {(doubanCardSubtitle || correctedDesc || detail?.desc) && ( +
+ {/* card_subtitle 在前,desc 在后 */} + {doubanCardSubtitle && ( +
+ {doubanCardSubtitle} +
+ )} + {correctedDesc || detail?.desc} +
+ )} + + + + {/* 封面展示 */} +
+
+
+ {videoCover ? ( + <> + {videoTitle} + + {/* 豆瓣链接按钮 */} + {videoDoubanId !== 0 && ( + +
+ + + + +
+
+ )} + + ) : ( + + 封面图片 + + )} +
+
- - - {/* 推荐区域 */} - + {/* 推荐区域 */} + - {/* 豆瓣评论区域 */} - {videoDoubanId !== 0 && enableComments && ( -
-
- {/* 标题 */} -
-

- - - - 豆瓣评论 -

+ {/* 豆瓣评论区域 */} + {videoDoubanId !== 0 && enableComments && ( +
+
+ {/* 标题 */} +
+

+ + + + 豆瓣评论 +

+
+ + {/* 评论内容 */} +
+ +
+
- - {/* 评论内容 */} -
- -
-
-
+ )} + )}
diff --git a/src/components/EpisodeSelector.tsx b/src/components/EpisodeSelector.tsx index ee82b8b..73ef15a 100644 --- a/src/components/EpisodeSelector.tsx +++ b/src/components/EpisodeSelector.tsx @@ -1,6 +1,6 @@ /* eslint-disable @next/next/no-img-element */ -import { Settings } from 'lucide-react'; +import { Link as LinkIcon, Settings } from 'lucide-react'; import { useRouter } from 'next/navigation'; import React, { useCallback, @@ -811,8 +811,10 @@ const EpisodeSelector: React.FC = ({ }`.trim()} > {/* 封面 */} -
- {source.poster && ( +
+ {source.source === 'directplay' ? ( + + ) : source.poster ? ( {source.title} = ({ target.style.display = 'none'; }} /> - )} + ) : null}
{/* 信息区域 */} diff --git a/src/components/VideoCard.tsx b/src/components/VideoCard.tsx index 5badba6..a170b05 100644 --- a/src/components/VideoCard.tsx +++ b/src/components/VideoCard.tsx @@ -167,6 +167,7 @@ const VideoCard = forwardRef(function VideoCard const actualYear = year; const actualQuery = query || ''; const actualSearchType = type; + const isDirectPlaySource = actualSource === 'directplay'; const displayYear = useMemo(() => { if (!actualYear) return ''; const normalized = actualYear.trim(); @@ -719,42 +720,47 @@ const VideoCard = forwardRef(function VideoCard }} > {/* 骨架屏 */} - {!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; - }} - /> + {!isLoading && !isDirectPlaySource && } + {isDirectPlaySource ? ( +
+ +
+ ) : ( + {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; + }} + /> + )} {/* 悬浮遮罩 */}