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

View File

@@ -42,11 +42,13 @@
"react-dom": "^18.2.0",
"react-icons": "^5.4.0",
"redis": "^4.6.7",
"sweetalert2": "^11.11.0",
"swiper": "^11.2.8",
"tailwind-merge": "^2.6.0",
"vidstack": "^0.6.15",
"zod": "^3.24.1",
"sweetalert2": "^11.11.0"
"xgplayer": "^3.0.22",
"xgplayer-hls.js": "^3.0.22",
"zod": "^3.24.1"
},
"devDependencies": {
"@commitlint/cli": "^16.3.0",

151
pnpm-lock.yaml generated
View File

@@ -86,6 +86,12 @@ importers:
vidstack:
specifier: ^0.6.15
version: 0.6.15
xgplayer:
specifier: ^3.0.22
version: 3.0.22(core-js@3.43.0)
xgplayer-hls.js:
specifier: ^3.0.22
version: 3.0.22(core-js@3.43.0)(xgplayer@3.0.22(core-js@3.43.0))
zod:
specifier: ^3.24.1
version: 3.25.67
@@ -2811,6 +2817,9 @@ packages:
core-js-compat@3.43.0:
resolution: {integrity: sha512-2GML2ZsCc5LR7hZYz4AXmjQw8zuy2T//2QntwdnpuYI7jteT6GVYJL7F6C2C57R7gSYrcqVW3lAALefdbhBLDA==}
core-js@3.43.0:
resolution: {integrity: sha512-N6wEbTTZSYOY2rYAn85CuvWWkCK6QweMn7/4Nr3w+gDBeBhk/x4EJeY6FPo4QzDoJZxVTv8U7CMvgWk6pOHHqA==}
cosmiconfig-typescript-loader@2.0.2:
resolution: {integrity: sha512-KmE+bMjWMXJbkWCeY4FJX/npHuZPNr9XF9q9CIQ/bpFwi1qHfCmSiKarrCcRa0LO4fWjk93pVoeRtJAkTGcYNw==}
engines: {node: '>=12', npm: '>=6'}
@@ -2883,9 +2892,16 @@ packages:
csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
d@1.0.2:
resolution: {integrity: sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==}
engines: {node: '>=0.12'}
damerau-levenshtein@1.0.8:
resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
danmu.js@1.1.13:
resolution: {integrity: sha512-knFd0/cB2HA4FFWiA7eB2suc5vCvoHdqio33FyyCSfP7C+1A+zQcTvnvwfxaZhrxsGj4qaQI2I8XiTqedRaVmg==}
dargs@7.0.0:
resolution: {integrity: sha512-2iy1EkLdlBzQGvbweYRFxmFath8+K7+AKB0TlhHWkNuH+TmovaMH/Wp7V7R4u7f4SnX3OgLsU9t1NI9ioDnUpg==}
engines: {node: '>=8'}
@@ -2952,6 +2968,10 @@ packages:
deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
deepmerge@2.0.1:
resolution: {integrity: sha512-VIPwiMJqJ13ZQfaCsIFnp5Me9tnjURiaIFxfz7EH0Ci0dTSQpZtSLrqOicXqEd/z2r+z+Klk9GzmnRsgpgbOsQ==}
engines: {node: '>=0.10.0'}
deepmerge@4.3.1:
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
engines: {node: '>=0.10.0'}
@@ -2975,6 +2995,9 @@ packages:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
delegate@3.2.0:
resolution: {integrity: sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw==}
depd@1.1.2:
resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==}
engines: {node: '>= 0.6'}
@@ -3045,6 +3068,9 @@ packages:
resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==}
engines: {node: '>=8'}
downloadjs@1.4.7:
resolution: {integrity: sha512-LN1gO7+u9xjU5oEScGFKvXhYf7Y/empUIIEAGBs1LzUq/rg5duiDrkuH5A2lQGd5jfMOb9X9usDa2oVXwJ0U/Q==}
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
@@ -3131,6 +3157,17 @@ packages:
resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==}
engines: {node: '>= 0.4'}
es5-ext@0.10.64:
resolution: {integrity: sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==}
engines: {node: '>=0.10'}
es6-iterator@2.0.3:
resolution: {integrity: sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==}
es6-symbol@3.1.4:
resolution: {integrity: sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==}
engines: {node: '>=0.12'}
esbuild-android-64@0.14.47:
resolution: {integrity: sha512-R13Bd9+tqLVFndncMHssZrPWe6/0Kpv2/dt4aA69soX4PRxlzsVpCvoJeFE8sOEoeVEiBkI0myjlkDodXlHa0g==}
engines: {node: '>=12'}
@@ -3520,6 +3557,10 @@ packages:
deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options.
hasBin: true
esniff@2.0.1:
resolution: {integrity: sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==}
engines: {node: '>=0.10'}
espree@9.6.1:
resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
@@ -3559,6 +3600,12 @@ packages:
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
engines: {node: '>= 0.6'}
event-emitter@0.3.5:
resolution: {integrity: sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==}
eventemitter3@4.0.7:
resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
events-intercept@2.0.0:
resolution: {integrity: sha512-blk1va0zol9QOrdZt0rFXo5KMkNPVSp92Eju/Qz8THwKWKRKeE0T8Br/1aW6+Edkyq9xHYgYxn2QtOnUKPUp+Q==}
@@ -3589,6 +3636,9 @@ packages:
exsolve@1.0.7:
resolution: {integrity: sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==}
ext@1.7.0:
resolution: {integrity: sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==}
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
@@ -4747,6 +4797,9 @@ packages:
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
next-tick@1.1.0:
resolution: {integrity: sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==}
next@14.2.30:
resolution: {integrity: sha512-+COdu6HQrHHFQ1S/8BBsCag61jZacmvbuL2avHvQFbWa2Ox7bE+d8FyNgxRLjXQ5wtPyQwEmk85js/AuaG2Sbg==}
engines: {node: '>=18.17.0'}
@@ -5957,6 +6010,9 @@ packages:
resolution: {integrity: sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==}
engines: {node: '>=14.16'}
type@2.7.3:
resolution: {integrity: sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==}
typed-array-buffer@1.0.3:
resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
engines: {node: '>= 0.4'}
@@ -6303,6 +6359,22 @@ packages:
resolution: {integrity: sha512-sqMMuL1rc0FmMBOzCpd0yuy9trqF2yTTVe+E9ogwCSWQCdDEtQUwrZPT6AxqtsFGRNxycgncbP/xmOOSPw5ZUw==}
engines: {node: '>= 6.0'}
xgplayer-hls.js@3.0.22:
resolution: {integrity: sha512-oNhiw1QnWNYmZsKnq7D8PUfAVHqAwv4kCwEwARMquGcUEZwNOOvazzZopTYPgwXj965LKSzgD36nhpbfYef/ZQ==}
peerDependencies:
core-js: '>=3.12.1'
xgplayer: '>=3.0.13'
xgplayer-subtitles@3.0.22:
resolution: {integrity: sha512-2XjamtZnWS/r4QjesOC34JmuGD3QPbgeqkI4t5Gq19dN1CWNBP7nJ8pbGLuAeHswKjGg8LFRpnsic7xjc/XSyA==}
peerDependencies:
core-js: '>=3.12.1'
xgplayer@3.0.22:
resolution: {integrity: sha512-uVKffa02NxWnWMVzgnrU0HGwZFH0ymPHsD3zGxtV6oPPplA6EBLyh9N5q3b++J7jRs2usvKR2+WslT+je1RuwA==}
peerDependencies:
core-js: '>=3.12.1'
xml-name-validator@3.0.0:
resolution: {integrity: sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==}
@@ -9355,6 +9427,8 @@ snapshots:
dependencies:
browserslist: 4.25.0
core-js@3.43.0: {}
cosmiconfig-typescript-loader@2.0.2(@types/node@24.0.3)(cosmiconfig@7.1.0)(typescript@4.9.5):
dependencies:
'@types/node': 24.0.3
@@ -9430,8 +9504,17 @@ snapshots:
csstype@3.1.3: {}
d@1.0.2:
dependencies:
es5-ext: 0.10.64
type: 2.7.3
damerau-levenshtein@1.0.8: {}
danmu.js@1.1.13:
dependencies:
event-emitter: 0.3.5
dargs@7.0.0: {}
data-uri-to-buffer@2.0.2: {}
@@ -9487,6 +9570,8 @@ snapshots:
deep-is@0.1.4: {}
deepmerge@2.0.1: {}
deepmerge@4.3.1: {}
define-data-property@1.1.4:
@@ -9515,6 +9600,8 @@ snapshots:
delayed-stream@1.0.0: {}
delegate@3.2.0: {}
depd@1.1.2: {}
dequal@2.0.3: {}
@@ -9576,6 +9663,8 @@ snapshots:
dependencies:
is-obj: 2.0.0
downloadjs@1.4.7: {}
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2
@@ -9730,6 +9819,24 @@ snapshots:
is-date-object: 1.1.0
is-symbol: 1.1.1
es5-ext@0.10.64:
dependencies:
es6-iterator: 2.0.3
es6-symbol: 3.1.4
esniff: 2.0.1
next-tick: 1.1.0
es6-iterator@2.0.3:
dependencies:
d: 1.0.2
es5-ext: 0.10.64
es6-symbol: 3.1.4
es6-symbol@3.1.4:
dependencies:
d: 1.0.2
ext: 1.7.0
esbuild-android-64@0.14.47:
optional: true
@@ -10140,6 +10247,13 @@ snapshots:
transitivePeerDependencies:
- supports-color
esniff@2.0.1:
dependencies:
d: 1.0.2
es5-ext: 0.10.64
event-emitter: 0.3.5
type: 2.7.3
espree@9.6.1:
dependencies:
acorn: 8.15.0
@@ -10168,6 +10282,13 @@ snapshots:
etag@1.8.1: {}
event-emitter@0.3.5:
dependencies:
d: 1.0.2
es5-ext: 0.10.64
eventemitter3@4.0.7: {}
events-intercept@2.0.0: {}
events@3.3.0: {}
@@ -10206,6 +10327,10 @@ snapshots:
exsolve@1.0.7: {}
ext@1.7.0:
dependencies:
type: 2.7.3
fast-deep-equal@3.1.3: {}
fast-glob@3.3.3:
@@ -11646,6 +11771,8 @@ snapshots:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
next-tick@1.1.0: {}
next@14.2.30(@babel/core@7.27.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
'@next/env': 14.2.30
@@ -12850,6 +12977,8 @@ snapshots:
type-fest@3.13.1: {}
type@2.7.3: {}
typed-array-buffer@1.0.3:
dependencies:
call-bound: 1.0.4
@@ -13361,6 +13490,28 @@ snapshots:
dependencies:
os-paths: 4.4.0
xgplayer-hls.js@3.0.22(core-js@3.43.0)(xgplayer@3.0.22(core-js@3.43.0)):
dependencies:
core-js: 3.43.0
deepmerge: 2.0.1
event-emitter: 0.3.5
hls.js: 1.6.5
xgplayer: 3.0.22(core-js@3.43.0)
xgplayer-subtitles@3.0.22(core-js@3.43.0):
dependencies:
core-js: 3.43.0
eventemitter3: 4.0.7
xgplayer@3.0.22(core-js@3.43.0):
dependencies:
core-js: 3.43.0
danmu.js: 1.1.13
delegate: 3.2.0
downloadjs: 1.4.7
eventemitter3: 4.0.7
xgplayer-subtitles: 3.0.22(core-js@3.43.0)
xml-name-validator@3.0.0: {}
xmlchars@2.2.0: {}

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;