feat: init xgplayer

This commit is contained in:
shinya
2025-07-06 22:34:27 +08:00
parent 85163964c6
commit e068869193
4 changed files with 578 additions and 2 deletions

250
src/app/new-play/page.tsx Normal file
View 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>
);
}

View 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;