feat: update video player

This commit is contained in:
LittleBoy 2025-04-22 16:14:41 +08:00
parent 74f37055bc
commit 0ccbfb5f5a
10 changed files with 435 additions and 95 deletions

View File

@ -1,10 +1,13 @@
import AppRouter from "@/routes";
import {ConfigProvider} from "@/contexts/config";
import {AuthProvider} from "@/contexts/auth";
import LivePlayer from "@/pages/live-player";
import React from 'react';
// import LivePlayer from "@/pages/live-player";
console.log(`APP-BUILD-AT: ${AppBuildVersion}`)
const LivePlayer = React.lazy(() => import('@/pages/live-player'));
function App() {
return (
<ConfigProvider>

View File

@ -0,0 +1,131 @@
import React, {useEffect, useState} from "react";
// import TCPlayer from 'tcplayer.js';
import flvPlayer, { VideoPlayer } from '@/components/video/VideoPlayer.ts';
import {PlayerInstance} from "@/hooks/useCache.ts";
// import 'tcplayer.js/dist/tcplayer.min.css';
type State = {
playing: boolean
muted: boolean
end?: boolean
error?: boolean
fullscreen: boolean
progress: number
playedSeconds: number
duration: number
}
type StateUpdate = Partial<State> | ((prev: State) => Partial<State>)
type Props = {
url?: string; cover?: string; showControls?: boolean; className?: string;
poster?: string;
onChange?: (state: State) => void;
onProgress?: (current:number,duration:number) => void;
muted?: boolean;
autoPlay?: boolean;
}
export type PlayerInstance = {
play: (url: string, currentTime: number) => void;
pause: () => void;
getState: () => State;
}
export const Mp4Player = React.forwardRef<PlayerInstance, Props>((props, ref) => {
const [tcPlayer, setTcPlayer] = useState<VideoPlayer | null>(null)
const [prevUrl, setPrevUrl] = useState<string | undefined>();
const [state, _setState] = useState<State>({
playing: false,
muted: false,
// 是否全屏
fullscreen: false,
progress: 0,
playedSeconds: 0,
duration: 0
})
const setState = (data: StateUpdate) => {
console.log('playstate change', data)
_setState(prev => {
const _state = typeof (data) === 'function' ? {...prev, ...data(prev)} : {...prev, ...data}
props.onChange?.(_state)
return _state
})
}
useEffect(()=>{
if(props.url && tcPlayer){
tcPlayer.src(props.url)
}
},[props.url, tcPlayer])
useEffect(() => {
const player = flvPlayer.newInstance({className:props.className});
document.querySelector('.video-player-container-inner')!.appendChild(player.getVideo())
// const player = TCPlayer(playerVideo, {
// controls: props.showControls,
// poster: props.poster,
// autoplay: typeof(props.autoPlay) != 'undefined' ? props.autoPlay : true,
// licenseUrl: AppConfig.TCPlayerLicense
// }
// ) as TCPlayerInstance;
player.on('pause', () => {
setState({playing: false, end: false, error: false})
})
player.on('playing', () => {
setState({playing: true, end: false, error: false})
})
player.on('ended', () => {
setState({end: true, playing: false, error: false})
})
player.on('timeupdate', () => {
props.onProgress?.(player.currentTime(), player.duration())
})
player.on('error', () => {
setState({end: false, playing: false, error: true})
})
setTcPlayer(() => player)
return () => {
console.log('destroy video')
try{
// player.unload();
player.dispose();
setTcPlayer(() => null)
//Array.from(document.querySelectorAll('video')).forEach(v => v.pause())
}catch (e){
console.log(e)
}
//playerVideo.parentElement?.removeChild(playerVideo)
}
}, [])
React.useImperativeHandle(ref, () => {
return {
pause(){
if (!tcPlayer) return;
tcPlayer.pause()
},
play: (url, currentTime = 0) => {
// console.log('play', url, currentTime)
url = url.replace('.flv','.mp4');
if (!tcPlayer) return;
const player = tcPlayer
if (prevUrl == url) {
player.currentTime(0)
} else {
player.src(url)
}
player.play()
setPrevUrl(url)
if (currentTime > 0) {
player.currentTime(currentTime)
}
},
getState: () => state
}
})
return <div className={`video-player relative ${props.className} video-player-container-inner`}>
</div>
})

View File

@ -0,0 +1,165 @@
type VideoPlayerEvents =
'playing'
| 'pause'
| 'ended'
| 'timeupdate'
| 'error'
| 'canplay'
| 'durationchange'
| 'progress'
type PlayerOptions = {
enableLog?: boolean;
className?: string;
}
enum PlayState {
playing = 'playing',
pause = 'pause',
ended = 'ended',
error = 'error',
}
export class VideoPlayer {
private video?: HTMLVideoElement;
private State: PlayState = PlayState.pause;
private enable_log = true;
private currentDuration = 0;
newInstance(options?: PlayerOptions) {
const { className, enableLog=true } = options || {};
this.enable_log = enableLog || false;
if (this.video) {
this.video.remove();
}
if (this.video) {
this.video.pause();
}
// Create video element
const playerVideo = document.createElement('video');
const playerId = `player-container-${Date.now().toString(16)}`;
playerVideo.setAttribute('id', playerId);
playerVideo.setAttribute('preload', 'auto');
playerVideo.setAttribute('playsInline', 'true');
playerVideo.setAttribute('webkit-playsinline', 'true');
if (className) playerVideo.setAttribute('className', className);
playerVideo.classList.add('digital-video-player');
playerVideo.addEventListener('durationchange', (e) => {
this.currentDuration = Math.floor(this.video?.duration || 0);
this.log('video duration change',e)
});
playerVideo.addEventListener('error', (e) => {
this.log('video error:',e)
});
this.video = playerVideo;
return this;
}
/**
*
* @param args
*/
log(...args: any[]) {
if (this.enable_log) {
console.log(...args);
}
}
getVideo() {
return this.video!;
}
// 暂停视频
pause() {
this.video?.pause();
// 更新状态
this.State = PlayState.pause;
}
/**
*
* @param event
* @param callback
*/
on(event: VideoPlayerEvents, callback: () => void) {
this.video?.addEventListener(event, callback);
}
off(event: VideoPlayerEvents, callback: () => void) {
this.video?.removeEventListener(event, callback);
}
/**
*
* @param time
*/
currentTime(time?: number) {
if (!this.video) return 0;
if (typeof time === 'number' && this.video.currentTime !== time) {
try {
this.video.currentTime = time;
} catch (err) {
this.log('Failed to set currentTime', err);
}
}
return this.video.currentTime || 0;
}
src(url: string) {
if (!this.video) return;
this.video.src = url;
this.video.load();
}
/**
*
*/
play() {
if (!this.video) return;
this.video.play().then(() => {
this.State = PlayState.playing;
}).catch((err) => {
this.State = PlayState.error;
this.log('play error', err);
});
}
duration() {
return this.currentDuration || this.video?.duration || 0;
}
dispose(){
if(!this.video){
return;
}
// 暂停
this.pause();
// 移除
this.video.parentNode?.removeChild(this.video);
this.video = undefined;
}
}
/**
* player.on('pause', () => {
* setState({playing: false, end: false, error: false})
* })
* player.on('playing', () => {
* setState({playing: true, end: false, error: false})
* })
* player.on('ended', () => {
* setState({end: true, playing: false, error: false})
* })
* player.on('timeupdate', () => {
* props.onProgress?.(player.currentTime(), player.duration())
* })
* player.on('error', () => {
* setState({end: false, playing: false, error: true})
* })
*/
const videoPlayer = new VideoPlayer();
export default videoPlayer;

View File

@ -1,11 +1,10 @@
// import ReactPlayer from 'react-player'
// import {PauseOutlined, PlayCircleOutlined, FullscreenOutlined, FullscreenExitOutlined} from "@ant-design/icons"
// import {Progress} from "antd";
import React, {useEffect, useState} from "react";
import {PlayerInstance} from "@/hooks/useCache.ts";
import React, { useEffect, useState } from 'react';
import TCPlayer from 'tcplayer.js';
import 'tcplayer.js/dist/tcplayer.min.css';
import { PlayerInstance } from '@/hooks/useCache.ts';
import videoPlayer from '@/components/video/VideoPlayer.ts';
type State = {
playing: boolean
muted: boolean
@ -22,7 +21,7 @@ type Props = {
url?: string; cover?: string; showControls?: boolean; className?: string;
poster?: string;
onChange?: (state: State) => void;
onProgress?: (current:number,duration:number) => void;
onProgress?: (current: number, duration: number) => void;
muted?: boolean;
autoPlay?: boolean;
}
@ -32,8 +31,9 @@ export type PlayerInstance = {
getState: () => State;
}
export const Player = React.forwardRef<PlayerInstance, Props>((props, ref) => {
const [tcPlayer, setTcPlayer] = useState<TCPlayer | null>(null)
const [prevUrl, setPrevUrl] = useState<string | undefined>();
const [tcPlayer, setTcPlayer] = useState<TCPlayerInstance | null>(null);
const [state, _setState] = useState<State>({
playing: false,
muted: false,
@ -42,105 +42,85 @@ export const Player = React.forwardRef<PlayerInstance, Props>((props, ref) => {
progress: 0,
playedSeconds: 0,
duration: 0
})
});
const setState = (data: StateUpdate) => {
console.log('playstate change', data)
console.log('playstate change', data);
_setState(prev => {
const _state = typeof (data) === 'function' ? {...prev, ...data(prev)} : {...prev, ...data}
props.onChange?.(_state)
return _state
})
}
useEffect(()=>{
if(props.url && tcPlayer){
tcPlayer.src(props.url)
}
},[props.url, tcPlayer])
const _state = typeof (data) === 'function' ? { ...prev, ...data(prev) } : { ...prev, ...data };
props.onChange?.(_state);
return _state;
});
};
useEffect(() => {
if(PlayerInstance.length != 0){
PlayerInstance.forEach(player => player.pause())
PlayerInstance.length = 0
if (props.url && tcPlayer) {
tcPlayer.src(props.url);
}
const playerVideo = document.createElement('video');
const playerId = `player-container-${Date.now().toString(16)}`;
playerVideo.setAttribute('id', playerId)
playerVideo.setAttribute('preload', 'auto')
playerVideo.setAttribute('playsInline', 'true')
playerVideo.setAttribute('webkit-playsinline', 'true')
if(props.className) playerVideo.setAttribute('className', props.className)
playerVideo.classList.add('digital-video-player')
PlayerInstance.push(playerVideo)
document.querySelector('.video-player-container-inner')!.appendChild(playerVideo)
}, [props.url, tcPlayer]);
const player = TCPlayer(playerId, {
useEffect(() => {
const playerVideo = videoPlayer.newInstance().getVideo();
document.querySelector('.video-player-container-inner')!.appendChild(playerVideo);
const flvPlayer = TCPlayer(playerVideo, {
//sources: [{src: props.url}],
controls: props.showControls,
// muted:props.muted,
poster: props.poster,
autoplay: typeof(props.autoPlay) != 'undefined' ? props.autoPlay : true,
autoplay: typeof (props.autoPlay) != 'undefined' ? props.autoPlay : true,
licenseUrl: 'https://license.vod2.myqcloud.com/license/v2/1328581896_1/v_cube.license'
}
)
player.on('pause', () => {
setState({playing: false, end: false, error: false})
})
player.on('playing', () => {
setState({playing: true, end: false, error: false})
})
player.on('ended', () => {
setState({end: true, playing: false, error: false})
})
player.on('timeupdate', () => {
props.onProgress?.(player.currentTime(), player.duration())
})
player.on('error', () => {
setState({end: false, playing: false, error: true})
})
setTcPlayer(() => player)
);
flvPlayer.on('pause', () => {
setState({ playing: false, end: false, error: false });
});
flvPlayer.on('playing', () => {
setState({ playing: true, end: false, error: false });
});
flvPlayer.on('ended', () => {
setState({ end: true, playing: false, error: false });
});
flvPlayer.on('timeupdate', () => {
props.onProgress?.(flvPlayer.currentTime(), flvPlayer.duration());
});
flvPlayer.on('error', () => {
setState({ end: false, playing: false, error: true });
});
setTcPlayer(() => flvPlayer);
return () => {
// if (tcPlayer) {
// tcPlayer.pause()
// tcPlayer.unload()
// }else{
// playerVideo.pause()
// }
console.log('destroy video')
try{
Array.from(document.querySelectorAll('video')).forEach(v => v.pause())
}catch (e){
console.log(e)
}
playerVideo.parentElement?.removeChild(playerVideo)
}
}, [])
tcPlayer?.pause();
console.log('destroy video');
tcPlayer?.dispose();
};
}, []);
React.useImperativeHandle(ref, () => {
return {
pause(){
pause() {
if (!tcPlayer) return;
tcPlayer.pause()
tcPlayer.pause();
},
play: (url, currentTime = 0) => {
console.log('play', url, currentTime)
console.log('play', url, currentTime);
if (!tcPlayer) return;
const player = tcPlayer
const player = tcPlayer;
if (prevUrl == url) {
player.currentTime(0)
player.currentTime(0);
} else {
player.src(url)
player.src(url);
}
player.play()
setPrevUrl(url)
player.play();
setPrevUrl(url);
if (currentTime > 0) {
player.currentTime(currentTime)
player.currentTime(currentTime);
}
},
getState: () => state
}
})
};
});
return <div className={`video-player relative ${props.className} video-player-container-inner`}>
</div>
})
</div>;
});

View File

@ -89,7 +89,7 @@ export const VideoListItem = (
>{index}</div>
<div className="col cover cursor-pointer" onClick={onItemClick}>
<div className="relative">
<img className="w-[100px] h-[56px] object-cover" src={video.cover || ImageCover}/>
<img className="w-[100px] h-[56px] object-cover border border-gray-200" src={video.cover || ImageCover}/>
{generating &&
<div
className={'absolute rounded inset-0 bg-black/40 backdrop-blur-[1px] text-white flex items-center justify-center'}>

View File

@ -1,6 +1,7 @@
import {useMount} from "ahooks";
import {getLiveUrl} from "@/service/api/live.ts";
import React, {useState} from "react";
import {getLiveUrl} from "@/service/api/live.ts";
import {Player} from "@/components/video/player.tsx";
import './style.scss'

View File

@ -76,7 +76,7 @@ export default function VideoIndex() {
// 播放视频
const playVideo = (video: VideoInfo) => {
console.log('play video',video)
if (state.playingId == video.id) {
player.current?.pause();
setState({playingId: -1})
@ -86,9 +86,9 @@ export default function VideoIndex() {
// setState({playingIndex})
// player.current?.play('https://staticplus.gachafun.com/ai-collect/composite_video/2024-12-17/1186196465916190720.flv', 30)
//
if (video.oss_video_url && video.status !== 1) {
if (video.oss_video_mp4_url && video.status !== 1) {
setState({playingId: video.id})
player.current?.play(video.oss_video_url, 0)
player.current?.play(video.oss_video_mp4_url, 0)
}
}
// 处理全选
@ -227,14 +227,6 @@ export default function VideoIndex() {
handleModifySort(newSorts)
return newSorts;
});
// modal.confirm({
// title: '提示',
// content: '是否要移动到指定位置',
// onOk: handleModifySort,
// onCancel: () => {
// setVideoData(originArr);
// }
// })
}
}}>
<SortableContext items={videoData}>

61
src/types/tcplayer.d.ts vendored Normal file
View File

@ -0,0 +1,61 @@
type TCPlayerEvents = 'play' // 已经开始播放,调用 play() 方法或者设置了 autoplay 为 true 且生效时触发,这时 paused 属性为 false。
| 'playing' // 因缓冲而暂停或停止后恢复播放时触发paused 属性为 false 。通常用这个事件来标记视频真正播放play 事件只是开始播放,画面并没有开始渲染。
| 'loadstart' // 开始加载数据时触发。
| 'durationchange' // 视频的时长数据发生变化时触发。
| 'loadedmetadata' // 已加载视频的 metadata。
| 'loadeddata' // 当前帧的数据已加载,但没有足够的数据来播放视频的下一帧时,触发该事件。
| 'progress' // 在获取到媒体数据时触发。
| 'canplay' // 当播放器能够开始播放视频时触发。
| 'canplaythrough' // 当播放器预计能够在不停下来进行缓冲的情况下持续播放指定的视频时触发。
| 'error' // 视频播放出现错误时触发。
| 'pause' // 暂停时触发。
| 'blocked' // 自动播放被浏览器阻止时触发。(原 2005 回调事件统一合并到 blocked 事件中)。
| 'ratechange' // 播放速率变更时触发。
| 'seeked' // 搜寻指定播放位置结束时触发。
| 'seeking' // 搜寻指定播放位置开始时触发。
| 'timeupdate' // 当前播放位置有变更,可以理解为 currentTime 有变更。
| 'volumechange' // 设置音量或者 muted 属性值变更时触发。
| 'waiting' // 播放停止,下一帧内容不可用时触发。
| 'ended' // 视频播放已结束时触发。此时 currentTime 值等于媒体资源最大值。
| 'resolutionswitching' // 清晰度切换进行中。
| 'resolutionswitched' // 清晰度切换完毕。
| 'fullscreenchange' //全屏状态切换时触发。
| 'webrtcevent' // 播放 webrtc 时的事件集合。
| 'webrtcstats' // 播放 webrtc 时的统计数据。
| 'webrtcfallback' // 播放 webrtc 时触发降级。
declare type TCPlayerInstance = {
//监听事件。
on: (event: TCPlayerEvents, callback: () => void) => void;
// 取消监听事件。
off: (event: TCPlayerEvents, callback: () => void) => void;
// 监听事件事件处理函数最多只执行1次。
one: (event: TCPlayerEvents, callback: () => void) => void;
// 设置播放地址。
src: (url: string) => void;
// 设置播放器初始化完成后的回调。
ready: (callback: () => void) => void;
play: () => void;
pause: () => void;
unload: () => void;
//获取或设置播放器是否静音。
muted: (mute: boolean) => boolean | void;
//获取或设置播放器音量。
volume: (percent: number) => number | void;
// 获取或设置播放倍速。
playbackRate: (percent: number) => number | void;
//获取当前播放时间点,或者设置播放时间点,该时间点不能超过视频时长。
currentTime: (seconds?: number) => number;
//获取视频时长。
duration: () => number;
// 销毁播放器。
dispose: () => number;
};
declare function TCPlayer(container: HTMLVideoElement | string, options: any): TCPlayerInstance;
declare module 'tcplayer.js' {
export default TCPlayer;
}

2
src/vite-env.d.ts vendored
View File

@ -14,6 +14,8 @@ declare const AppConfig: {
API_PREFIX: string;
ONLY_LIVE: string;
APP_LANG: string;
// 腾讯播放器
TCPlayerLicense: string;
};
declare const AppMode: 'test' | 'production' | 'development';

View File

@ -4,6 +4,10 @@ import {resolve} from "path";
import AppPackage from './package.json'
import dayjs from "dayjs";
// 播放器 SDK Web 端TCPlayer自 5.0.0 版本起需获取 License 授权后方可使用。
// <p>https://cloud.tencent.com/document/product/881/77877#.E5.87.86.E5.A4.87.E5.B7.A5.E4.BD.9C</p>
const TCPlayerLicense = 'https://license.vod2.myqcloud.com/license/v2/1328581896_1/v_cube.license'
const DevServerList:{
[key:string]:string
} = {
@ -30,7 +34,8 @@ export default defineConfig(({mode}) => {
AUTH_TOKEN_KEY: process.env.AUTH_TOKEN_KEY || AUTH_TOKEN_KEY,
AUTHED_PERSON_DATA_KEY: process.env.AUTHED_PERSON_DATA_KEY || 'digital-person-user-info',
ONLY_LIVE: process.env.ONLY_LIVE || 'no',
APP_LANG: process.env.APP_LANGUAGE
APP_LANG: process.env.APP_LANGUAGE,
TCPlayerLicense
}),
AppMode: JSON.stringify(mode),
AppBuildVersion: JSON.stringify(AppPackage.name + '-' + AppPackage.version + '-' + dayjs().format('YYYYMMDDHH_mmss'))