直链播放

This commit is contained in:
mtvpls
2026-01-26 17:40:09 +08:00
parent 73232fd172
commit f4a4768c16
4 changed files with 478 additions and 293 deletions
+85 -2
View File
@@ -2,8 +2,9 @@
'use client'; '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 Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Suspense, useEffect, useState } from 'react'; import { Suspense, useEffect, useState } from 'react';
import { import {
@@ -13,7 +14,7 @@ import {
import { getDoubanCategories } from '@/lib/douban.client'; import { getDoubanCategories } from '@/lib/douban.client';
import { getTMDBImageUrl, TMDBItem } from '@/lib/tmdb.client'; import { getTMDBImageUrl, TMDBItem } from '@/lib/tmdb.client';
import { DoubanItem } from '@/lib/types'; import { DoubanItem } from '@/lib/types';
import { processImageUrl } from '@/lib/utils'; import { base58Encode, processImageUrl } from '@/lib/utils';
import AIChatPanel from '@/components/AIChatPanel'; import AIChatPanel from '@/components/AIChatPanel';
import BannerCarousel from '@/components/BannerCarousel'; import BannerCarousel from '@/components/BannerCarousel';
@@ -45,6 +46,7 @@ function HomeClient() {
>([]); >([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const { announcement } = useSite(); const { announcement } = useSite();
const router = useRouter();
// 首页模块配置状态 // 首页模块配置状态
const [homeModules, setHomeModules] = useState<HomeModule[]>([ const [homeModules, setHomeModules] = useState<HomeModule[]>([
@@ -64,6 +66,23 @@ function HomeClient() {
const [aiEnabled, setAiEnabled] = useState(false); const [aiEnabled, setAiEnabled] = useState(false);
const [aiDefaultMessageNoVideo, setAiDefaultMessageNoVideo] = useState('你好!我是MoonTVPlus的AI影视助手。想看什么电影或剧集?需要推荐吗?'); const [aiDefaultMessageNoVideo, setAiDefaultMessageNoVideo] = useState('你好!我是MoonTVPlus的AI影视助手。想看什么电影或剧集?需要推荐吗?');
const [sourceSearchEnabled, setSourceSearchEnabled] = useState(true); 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 = () => { const loadHomeLayoutSettings = () => {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
@@ -566,6 +585,14 @@ function HomeClient() {
<> <>
{/* 源站寻片和AI问片入口 */} {/* 源站寻片和AI问片入口 */}
<div className={`flex items-center justify-end gap-2 mb-4 ${homeBannerEnabled ? '' : 'mt-[30px]'}`}> <div className={`flex items-center justify-end gap-2 mb-4 ${homeBannerEnabled ? '' : 'mt-[30px]'}`}>
<button
onClick={handleDirectPlay}
className='p-1.5 rounded-lg text-blue-500 hover:text-blue-600 transition-colors'
title='直链播放'
>
<LinkIcon size={18} />
</button>
{/* 源站寻片入口 */} {/* 源站寻片入口 */}
{sourceSearchEnabled && ( {sourceSearchEnabled && (
<Link href='/source-search'> <Link href='/source-search'>
@@ -635,6 +662,62 @@ function HomeClient() {
</div> </div>
</div> </div>
)} )}
{showDirectPlayDialog && (
<div
className='fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4'
onClick={() => setShowDirectPlayDialog(false)}
>
<div
className='bg-white dark:bg-gray-900 rounded-lg shadow-xl w-full max-w-lg'
onClick={(event) => event.stopPropagation()}
>
<div className='flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700'>
<h3 className='text-lg font-semibold text-gray-900 dark:text-gray-100'>
</h3>
<button
onClick={() => setShowDirectPlayDialog(false)}
className='p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors'
aria-label='关闭'
>
<span className='text-gray-600 dark:text-gray-400'>×</span>
</button>
</div>
<div className='p-4 space-y-4'>
<div className='text-sm text-gray-600 dark:text-gray-300'>
</div>
<input
value={directPlayUrl}
onChange={(event) => 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'
/>
<div className='flex justify-end gap-2'>
<button
onClick={() => setShowDirectPlayDialog(false)}
className='px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-700 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors'
>
</button>
<button
onClick={submitDirectPlay}
disabled={!directPlayUrl.trim()}
className='px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed'
>
</button>
</div>
</div>
</div>
</div>
)}
</PageLayout> </PageLayout>
); );
} }
+106 -12
View File
@@ -48,7 +48,7 @@ import {
import { getDoubanDetail } from '@/lib/douban.client'; import { getDoubanDetail } from '@/lib/douban.client';
import { getTMDBImageUrl } from '@/lib/tmdb.search'; import { getTMDBImageUrl } from '@/lib/tmdb.search';
import { DanmakuFilterConfig, EpisodeFilterConfig,SearchResult } from '@/lib/types'; 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 { useEnableComments } from '@/hooks/useEnableComments';
import { usePlaySync } from '@/hooks/usePlaySync'; import { usePlaySync } from '@/hooks/usePlaySync';
@@ -488,6 +488,7 @@ function PlayPageClient() {
const [currentSource, setCurrentSource] = useState(searchParams.get('source') || ''); const [currentSource, setCurrentSource] = useState(searchParams.get('source') || '');
const [currentId, setCurrentId] = useState(searchParams.get('id') || ''); const [currentId, setCurrentId] = useState(searchParams.get('id') || '');
const [fileName] = useState(searchParams.get('fileName') || ''); // 小雅源:用户点击的文件名 const [fileName] = useState(searchParams.get('fileName') || ''); // 小雅源:用户点击的文件名
const isDirectPlay = currentSource === 'directplay';
// 解析 source 参数以获取 embyKey(仅用于 API 调用) // 解析 source 参数以获取 embyKey(仅用于 API 调用)
const parseSourceForApi = (source: string): { source: string; embyKey?: string } => { const parseSourceForApi = (source: string): { source: string; embyKey?: string } => {
@@ -601,6 +602,10 @@ function PlayPageClient() {
return; return;
} }
if (isDirectPlay) {
return;
}
// 检查是否禁用了自动装填弹幕 // 检查是否禁用了自动装填弹幕
const disableAutoLoad = localStorage.getItem('disableAutoLoadDanmaku') === 'true'; const disableAutoLoad = localStorage.getItem('disableAutoLoadDanmaku') === 'true';
if (disableAutoLoad) { if (disableAutoLoad) {
@@ -938,11 +943,19 @@ function PlayPageClient() {
}; };
loadDanmakuForCurrentEpisode(); loadDanmakuForCurrentEpisode();
}, [currentEpisodeIndex, videoTitle, loading]); }, [currentEpisodeIndex, videoTitle, loading, isDirectPlay]);
// 获取豆瓣评分数据 // 获取豆瓣评分数据
useEffect(() => { useEffect(() => {
const fetchDoubanRating = async () => { const fetchDoubanRating = async () => {
if (isDirectPlay) {
setDoubanRating(null);
setDoubanCardSubtitle('');
setDoubanAka([]);
setDoubanYear('');
return;
}
if (!videoDoubanId || videoDoubanId === 0) { if (!videoDoubanId || videoDoubanId === 0) {
setDoubanRating(null); setDoubanRating(null);
setDoubanCardSubtitle(''); setDoubanCardSubtitle('');
@@ -994,11 +1007,16 @@ function PlayPageClient() {
}; };
fetchDoubanRating(); fetchDoubanRating();
}, [videoDoubanId]); }, [videoDoubanId, isDirectPlay]);
// 获取TMDB背景图 // 获取TMDB背景图
useEffect(() => { useEffect(() => {
const fetchTMDBBackdrop = async () => { const fetchTMDBBackdrop = async () => {
if (isDirectPlay) {
setTmdbBackdrop(null);
return;
}
// 检查是否禁用背景图 // 检查是否禁用背景图
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
const disabled = localStorage.getItem('tmdb_backdrop_disabled'); const disabled = localStorage.getItem('tmdb_backdrop_disabled');
@@ -1146,7 +1164,7 @@ function PlayPageClient() {
}; };
fetchTMDBBackdrop(); fetchTMDBBackdrop();
}, [videoTitle, videoDoubanId]); }, [videoTitle, videoDoubanId, isDirectPlay]);
// 视频播放地址 // 视频播放地址
@@ -1168,6 +1186,14 @@ function PlayPageClient() {
// 总集数 // 总集数
const totalEpisodes = detail?.episodes?.length || 0; 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 后跳转到指定进度 // 用于记录是否需要在播放器 ready 后跳转到指定进度
const resumeTimeRef = useRef<number | null>(null); const resumeTimeRef = useRef<number | null>(null);
@@ -2808,6 +2834,73 @@ function PlayPageClient() {
}; };
const initAll = async () => { 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) { if (!currentSource && !currentId && !videoTitle && !searchTitle) {
setError('缺少必要参数'); setError('缺少必要参数');
setLoading(false); setLoading(false);
@@ -3698,6 +3791,7 @@ function PlayPageClient() {
// 预加载下一集弹幕(完全复制 loadDanmakuForCurrentEpisode 的逻辑) // 预加载下一集弹幕(完全复制 loadDanmakuForCurrentEpisode 的逻辑)
const preloadNextEpisodeDanmaku = async () => { const preloadNextEpisodeDanmaku = async () => {
try { try {
if (isDirectPlay) return;
const disableAutoLoad = localStorage.getItem('disableAutoLoadDanmaku') === 'true'; const disableAutoLoad = localStorage.getItem('disableAutoLoadDanmaku') === 'true';
if (disableAutoLoad) return; if (disableAutoLoad) return;
@@ -4043,6 +4137,7 @@ function PlayPageClient() {
// 自动搜索并加载弹幕 // 自动搜索并加载弹幕
const autoSearchDanmaku = async () => { const autoSearchDanmaku = async () => {
if (isDirectPlay) return;
const disableAutoLoad = localStorage.getItem('disableAutoLoadDanmaku') === 'true'; const disableAutoLoad = localStorage.getItem('disableAutoLoadDanmaku') === 'true';
if (disableAutoLoad) return; if (disableAutoLoad) return;
@@ -4616,9 +4711,7 @@ function PlayPageClient() {
// 非WebKit浏览器且播放器已存在,使用switch方法切换 // 非WebKit浏览器且播放器已存在,使用switch方法切换
if (!isWebkit && artPlayerRef.current) { if (!isWebkit && artPlayerRef.current) {
artPlayerRef.current.switch = videoUrl; artPlayerRef.current.switch = videoUrl;
artPlayerRef.current.title = `${videoTitle} - ${ artPlayerRef.current.title = `${videoTitle} - ${playerEpisodeLabel}`;
currentEpisodeIndex + 1
}`;
artPlayerRef.current.poster = videoCover; artPlayerRef.current.poster = videoCover;
if (artPlayerRef.current?.video) { if (artPlayerRef.current?.video) {
ensureVideoSource( ensureVideoSource(
@@ -6993,12 +7086,9 @@ function PlayPageClient() {
<h1 className={`text-xl font-semibold flex items-center gap-2 flex-wrap ${tmdbBackdrop ? 'text-white' : 'text-gray-900 dark:text-gray-100'}`}> <h1 className={`text-xl font-semibold flex items-center gap-2 flex-wrap ${tmdbBackdrop ? 'text-white' : 'text-gray-900 dark:text-gray-100'}`}>
<span> <span>
{videoTitle || '影片标题'} {videoTitle || '影片标题'}
{totalEpisodes > 1 && ( {shouldShowEpisodeLabel && (
<span className={tmdbBackdrop ? 'text-white opacity-80' : 'text-gray-500 dark:text-gray-400'}> <span className={tmdbBackdrop ? 'text-white opacity-80' : 'text-gray-500 dark:text-gray-400'}>
{` > ${ {` > ${episodeLabel}`}
detail?.episodes_titles?.[currentEpisodeIndex] ||
`${currentEpisodeIndex + 1}`
}`}
</span> </span>
)} )}
</span> </span>
@@ -7568,6 +7658,8 @@ function PlayPageClient() {
</div> </div>
</div> </div>
{!isDirectPlay && (
<>
{/* 详情展示 */} {/* 详情展示 */}
<div className='grid grid-cols-1 md:grid-cols-5 lg:grid-cols-6 gap-4'> <div className='grid grid-cols-1 md:grid-cols-5 lg:grid-cols-6 gap-4'>
{/* 文字区 */} {/* 文字区 */}
@@ -7817,6 +7909,8 @@ function PlayPageClient() {
</div> </div>
</div> </div>
)} )}
</>
)}
</div> </div>
{/* Toast通知 */} {/* Toast通知 */}
+6 -4
View File
@@ -1,6 +1,6 @@
/* eslint-disable @next/next/no-img-element */ /* 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 { useRouter } from 'next/navigation';
import React, { import React, {
useCallback, useCallback,
@@ -811,8 +811,10 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
}`.trim()} }`.trim()}
> >
{/* 封面 */} {/* 封面 */}
<div className='flex-shrink-0 w-12 h-20 bg-gray-300 dark:bg-gray-600 rounded overflow-hidden'> <div className='flex-shrink-0 w-12 h-20 bg-gray-300 dark:bg-gray-600 rounded overflow-hidden flex items-center justify-center'>
{source.poster && ( {source.source === 'directplay' ? (
<LinkIcon className='w-6 h-6 text-blue-500' />
) : source.poster ? (
<img <img
src={processImageUrl(source.poster)} src={processImageUrl(source.poster)}
alt={source.title} alt={source.title}
@@ -822,7 +824,7 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
target.style.display = 'none'; target.style.display = 'none';
}} }}
/> />
)} ) : null}
</div> </div>
{/* 信息区域 */} {/* 信息区域 */}
+8 -2
View File
@@ -167,6 +167,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
const actualYear = year; const actualYear = year;
const actualQuery = query || ''; const actualQuery = query || '';
const actualSearchType = type; const actualSearchType = type;
const isDirectPlaySource = actualSource === 'directplay';
const displayYear = useMemo(() => { const displayYear = useMemo(() => {
if (!actualYear) return ''; if (!actualYear) return '';
const normalized = actualYear.trim(); const normalized = actualYear.trim();
@@ -719,8 +720,12 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
}} }}
> >
{/* 骨架屏 */} {/* 骨架屏 */}
{!isLoading && <ImagePlaceholder aspectRatio={orientation === 'horizontal' ? 'aspect-[3/2]' : 'aspect-[2/3]'} />} {!isLoading && !isDirectPlaySource && <ImagePlaceholder aspectRatio={orientation === 'horizontal' ? 'aspect-[3/2]' : 'aspect-[2/3]'} />}
{/* 图片 */} {isDirectPlaySource ? (
<div className='absolute inset-0 flex items-center justify-center bg-gray-200/80 dark:bg-gray-700/80'>
<Link className='w-8 h-8 text-blue-500' />
</div>
) : (
<Image <Image
src={processImageUrl(actualPoster)} src={processImageUrl(actualPoster)}
alt={actualTitle} alt={actualTitle}
@@ -755,6 +760,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
return false; return false;
}} }}
/> />
)}
{/* 悬浮遮罩 */} {/* 悬浮遮罩 */}
<div <div