mirror of
https://github.com/MoonTechLab/LunaTV.git
synced 2026-04-29 05:47:30 +08:00
feat: init xgplayer
This commit is contained in:
250
src/app/new-play/page.tsx
Normal file
250
src/app/new-play/page.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
/* eslint-disable @typescript-eslint/ban-ts-comment, @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps, no-console, @next/next/no-img-element */
|
||||
|
||||
'use client';
|
||||
|
||||
import { Suspense, useEffect, useRef } from 'react';
|
||||
|
||||
import 'xgplayer/dist/index.min.css';
|
||||
|
||||
import EpisodeSelector from '@/components/EpisodeSelector';
|
||||
import PageLayout from '@/components/PageLayout';
|
||||
|
||||
function PlayPageClient() {
|
||||
const playerRef = useRef<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const initPlayer = async () => {
|
||||
try {
|
||||
// 动态导入 xgplayer 和 HLS 插件
|
||||
const [{ default: Player }, { default: HLS }] = await Promise.all([
|
||||
import('xgplayer'),
|
||||
import('xgplayer-hls.js'),
|
||||
]);
|
||||
|
||||
console.log('Player:', Player);
|
||||
console.log('HLS Plugin:', HLS);
|
||||
|
||||
// 初始化 xgplayer
|
||||
const player = new Player({
|
||||
id: 'player-container',
|
||||
// 示例 HLS 流地址,您可以根据需要替换
|
||||
url: 'https://vip.dytt-cinema.com/20250607/24158_8e0c4a20/index.m3u8',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
// 播放器配置
|
||||
autoplay: true,
|
||||
preload: 'auto',
|
||||
// 禁用响应式设计,保持原始大小
|
||||
fluid: false,
|
||||
// 保持视频原始尺寸和宽高比
|
||||
videoFillMode: 'contain',
|
||||
// 禁用自动调整尺寸
|
||||
autoResize: false,
|
||||
// 保持原始视频比例
|
||||
aspectRatio: 'auto',
|
||||
// 控制栏配置
|
||||
controls: true,
|
||||
// 海报图片
|
||||
poster: '',
|
||||
// 其他配置
|
||||
playsinline: true,
|
||||
lang: 'zh-cn',
|
||||
volume: 0.8,
|
||||
// 插件配置 - 注册 HLS 插件
|
||||
plugins: [HLS],
|
||||
// HLS 特定配置
|
||||
hls: {
|
||||
// 使用 hls.js 引擎
|
||||
enableWorker: true,
|
||||
// 预加载配置
|
||||
maxBufferLength: 30,
|
||||
maxBufferSize: 60 * 1000 * 1000,
|
||||
// 错误处理
|
||||
enableSoftwareAES: true,
|
||||
debug: process.env.NODE_ENV === 'development',
|
||||
// 自适应码率
|
||||
enableLowInitialPlaylist: false,
|
||||
// 跨域配置
|
||||
withCredentials: false,
|
||||
// 错误恢复
|
||||
maxMaxBufferLength: 600,
|
||||
backBufferLength: 90,
|
||||
// 性能优化
|
||||
liveSyncDurationCount: 3,
|
||||
liveMaxLatencyDurationCount: 10,
|
||||
},
|
||||
});
|
||||
|
||||
// 保存播放器实例
|
||||
playerRef.current = player;
|
||||
|
||||
// 添加事件监听器
|
||||
player.on('ready', () => {
|
||||
console.log('播放器已准备就绪');
|
||||
});
|
||||
|
||||
player.on('play', () => {
|
||||
console.log('开始播放');
|
||||
});
|
||||
|
||||
player.on('pause', () => {
|
||||
console.log('暂停播放');
|
||||
});
|
||||
|
||||
player.on('error', (err: any) => {
|
||||
console.error('播放器错误:', err);
|
||||
});
|
||||
|
||||
// HLS 特定事件
|
||||
player.on('hls_manifest_parsed', () => {
|
||||
console.log('HLS 清单解析完成');
|
||||
});
|
||||
|
||||
player.on('hls_level_switched', (event: any) => {
|
||||
console.log('HLS 质量切换:', event);
|
||||
});
|
||||
|
||||
player.on('hls_media_attached', () => {
|
||||
console.log('HLS 媒体已附加');
|
||||
});
|
||||
|
||||
player.on('hls_manifest_loaded', () => {
|
||||
console.log('HLS 清单已加载');
|
||||
});
|
||||
|
||||
player.on('hls_level_loaded', () => {
|
||||
console.log('HLS 级别已加载');
|
||||
});
|
||||
|
||||
player.on('hls_frag_loaded', () => {
|
||||
console.log('HLS 片段已加载');
|
||||
});
|
||||
|
||||
player.on('hls_error', (err: any) => {
|
||||
console.error('HLS 错误:', err);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('初始化播放器失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 初始化播放器
|
||||
initPlayer();
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
if (playerRef.current) {
|
||||
try {
|
||||
playerRef.current.destroy();
|
||||
} catch (error) {
|
||||
console.error('销毁播放器失败:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<PageLayout activePath='/play'>
|
||||
<div className='flex flex-col gap-6 py-4 px-10 md:px-32'>
|
||||
{/* 第一行:影片标题 */}
|
||||
<div className='py-1'>
|
||||
<h1 className='text-xl font-semibold text-gray-900 dark:text-gray-100'>
|
||||
影片标题
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* 第二行:播放器和选集 */}
|
||||
<div className='grid grid-cols-1 md:grid-cols-4 gap-4 md:h-[650px]'>
|
||||
{/* 播放器 */}
|
||||
<div className='md:col-span-3 h-[400px] md:h-full'>
|
||||
<div
|
||||
id='player-container'
|
||||
className='bg-black w-full h-full rounded-lg dark:border dark:border-white/10'
|
||||
></div>
|
||||
</div>
|
||||
|
||||
{/* 选集 */}
|
||||
<div className='md:col-span-1 h-full md:overflow-hidden'>
|
||||
<EpisodeSelector totalEpisodes={300} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 海报(移动端在上方) */}
|
||||
<div className='block md:hidden'>
|
||||
<div className='p-4'>
|
||||
<div className='bg-gray-300 dark:bg-gray-700 aspect-[3/4] max-w-xs mx-auto flex items-center justify-center'>
|
||||
<span className='text-gray-600 dark:text-gray-400'>封面图片</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 详情展示 */}
|
||||
<div className='grid grid-cols-1 md:grid-cols-4 gap-4'>
|
||||
{/* 文字区 */}
|
||||
<div className='md:col-span-3'>
|
||||
<div className='p-6'>
|
||||
<h2 className='text-2xl font-bold mb-4'>影片详情</h2>
|
||||
<div className='space-y-4'>
|
||||
<div>
|
||||
<h3 className='font-semibold text-lg mb-2'>剧情简介</h3>
|
||||
<p className='text-gray-700 dark:text-gray-300 leading-relaxed'>
|
||||
这里是影片的详细剧情介绍。可以包含影片的背景、故事梗概、主要人物关系等信息。
|
||||
文字内容可以根据实际的影片数据动态显示。
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className='font-semibold text-lg mb-2'>基本信息</h3>
|
||||
<div className='grid grid-cols-2 gap-4 text-sm'>
|
||||
<div>
|
||||
<span className='font-medium'>导演:</span>
|
||||
<span className='text-gray-700 dark:text-gray-300'>
|
||||
待获取
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className='font-medium'>主演:</span>
|
||||
<span className='text-gray-700 dark:text-gray-300'>
|
||||
待获取
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className='font-medium'>类型:</span>
|
||||
<span className='text-gray-700 dark:text-gray-300'>
|
||||
待获取
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className='font-medium'>年份:</span>
|
||||
<span className='text-gray-700 dark:text-gray-300'>
|
||||
待获取
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 封面展示(桌面端在右侧) */}
|
||||
<div className='hidden md:block md:col-span-1'>
|
||||
<div className='p-4'>
|
||||
<div className='bg-gray-300 dark:bg-gray-700 aspect-[3/4] flex items-center justify-center'>
|
||||
<span className='text-gray-600 dark:text-gray-400'>
|
||||
封面图片
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PlayPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<PlayPageClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
173
src/components/EpisodeSelector.tsx
Normal file
173
src/components/EpisodeSelector.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
interface EpisodeSelectorProps {
|
||||
/** 总集数 */
|
||||
totalEpisodes: number;
|
||||
/** 每页显示多少集,默认 50 */
|
||||
episodesPerPage?: number;
|
||||
/** 当前选中的集数(1 开始) */
|
||||
value?: number;
|
||||
/** 用户点击选集后的回调 */
|
||||
onChange?: (episodeNumber: number) => void;
|
||||
/** 额外 className */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 选集组件,支持分页与自动滚动聚焦当前分页标签。
|
||||
*/
|
||||
const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
||||
totalEpisodes,
|
||||
episodesPerPage = 50,
|
||||
value = 1,
|
||||
onChange,
|
||||
className = '',
|
||||
}) => {
|
||||
const pageCount = Math.ceil(totalEpisodes / episodesPerPage);
|
||||
|
||||
// 当前分页索引(0 开始)
|
||||
const initialPage = Math.floor((value - 1) / episodesPerPage);
|
||||
const [currentPage, setCurrentPage] = useState<number>(initialPage);
|
||||
|
||||
// 是否倒序显示
|
||||
const [descending, setDescending] = useState<boolean>(false);
|
||||
|
||||
// 升序分页标签
|
||||
const categoriesAsc = useMemo(() => {
|
||||
return Array.from({ length: pageCount }, (_, i) => {
|
||||
const start = i * episodesPerPage + 1;
|
||||
const end = Math.min(start + episodesPerPage - 1, totalEpisodes);
|
||||
return `${start}-${end}`;
|
||||
});
|
||||
}, [pageCount, episodesPerPage, totalEpisodes]);
|
||||
|
||||
// 分页标签始终保持升序
|
||||
const categories = categoriesAsc;
|
||||
|
||||
const categoryContainerRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
|
||||
// 当分页切换时,将激活的分页标签滚动到视口中间
|
||||
useEffect(() => {
|
||||
const btn = buttonRefs.current[currentPage];
|
||||
if (btn) {
|
||||
btn.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
inline: 'center',
|
||||
block: 'nearest',
|
||||
});
|
||||
}
|
||||
}, [currentPage, pageCount]);
|
||||
|
||||
const handleCategoryClick = useCallback((index: number) => {
|
||||
setCurrentPage(index);
|
||||
}, []);
|
||||
|
||||
const handleEpisodeClick = useCallback(
|
||||
(episodeNumber: number) => {
|
||||
onChange?.(episodeNumber);
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
const currentStart = currentPage * episodesPerPage + 1;
|
||||
const currentEnd = Math.min(
|
||||
currentStart + episodesPerPage - 1,
|
||||
totalEpisodes
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`md:ml-6 px-6 py-3 h-full rounded-lg bg-black/10 dark:bg-white/5 flex flex-col ${className}`.trim()}
|
||||
>
|
||||
{/* 分类标签 */}
|
||||
<div className='flex items-center gap-4 mb-4 border-b border-gray-300 dark:border-gray-700 -mx-6 px-6 flex-shrink-0'>
|
||||
<div className='flex-1 overflow-x-auto' ref={categoryContainerRef}>
|
||||
<div className='flex gap-2 min-w-max'>
|
||||
{categories.map((label, idx) => {
|
||||
const isActive = idx === currentPage;
|
||||
return (
|
||||
<button
|
||||
key={label}
|
||||
ref={(el) => {
|
||||
buttonRefs.current[idx] = el;
|
||||
}}
|
||||
onClick={() => handleCategoryClick(idx)}
|
||||
className={`w-20 relative py-2 text-sm font-medium transition-colors whitespace-nowrap flex-shrink-0 text-center
|
||||
${
|
||||
isActive
|
||||
? 'text-green-500 dark:text-green-400'
|
||||
: 'text-gray-700 hover:text-green-600 dark:text-gray-300 dark:hover:text-green-400'
|
||||
}
|
||||
`.trim()}
|
||||
>
|
||||
{label}
|
||||
{isActive && (
|
||||
<div className='absolute bottom-0 left-0 right-0 h-0.5 bg-green-500 dark:bg-green-400' />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{/* 向上/向下按钮占位,可根据实际需求添加功能 */}
|
||||
<button
|
||||
className='flex-shrink-0 w-8 h-8 rounded-md flex items-center justify-center text-gray-700 hover:text-green-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:text-green-400 dark:hover:bg-white/20 transition-colors transform translate-y-[-4px]'
|
||||
onClick={() => {
|
||||
// 切换集数排序(正序/倒序)
|
||||
setDescending((prev) => !prev);
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
className='w-4 h-4'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
viewBox='0 0 24 24'
|
||||
>
|
||||
<path
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth='2'
|
||||
d='M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4'
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 集数网格 */}
|
||||
<div className='grid grid-cols-[repeat(auto-fill,minmax(48px,1fr))] gap-3 overflow-y-auto h-full'>
|
||||
{(() => {
|
||||
const len = currentEnd - currentStart + 1;
|
||||
const episodes = Array.from({ length: len }, (_, i) =>
|
||||
descending ? currentEnd - i : currentStart + i
|
||||
);
|
||||
return episodes;
|
||||
})().map((episodeNumber) => {
|
||||
const isActive = episodeNumber === value;
|
||||
return (
|
||||
<button
|
||||
key={episodeNumber}
|
||||
onClick={() => handleEpisodeClick(episodeNumber)}
|
||||
className={`h-10 flex items-center justify-center text-sm font-medium rounded-md transition-all duration-200
|
||||
${
|
||||
isActive
|
||||
? 'bg-green-500 text-white shadow-lg shadow-green-500/25 dark:bg-green-600'
|
||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300 hover:scale-105 dark:bg-white/10 dark:text-gray-300 dark:hover:bg-white/20'
|
||||
}`.trim()}
|
||||
>
|
||||
{episodeNumber}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EpisodeSelector;
|
||||
Reference in New Issue
Block a user