From 0ccbfb5f5af55c69f21b8c48a3d3014aafd8f3a5 Mon Sep 17 00:00:00 2001 From: callmeyan Date: Tue, 22 Apr 2025 16:14:41 +0800 Subject: [PATCH] feat: update video player --- src/App.tsx | 5 +- src/components/video/Mp4Player.tsx | 131 ++++++++++++++++++ src/components/video/VideoPlayer.ts | 165 +++++++++++++++++++++++ src/components/video/player.tsx | 140 +++++++++---------- src/components/video/video-list-item.tsx | 2 +- src/pages/live-player/index.tsx | 3 +- src/pages/video/index.tsx | 14 +- src/types/tcplayer.d.ts | 61 +++++++++ src/vite-env.d.ts | 2 + vite.config.ts | 7 +- 10 files changed, 435 insertions(+), 95 deletions(-) create mode 100644 src/components/video/Mp4Player.tsx create mode 100644 src/components/video/VideoPlayer.ts create mode 100644 src/types/tcplayer.d.ts diff --git a/src/App.tsx b/src/App.tsx index 3d6f3ee..ec72010 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 ( diff --git a/src/components/video/Mp4Player.tsx b/src/components/video/Mp4Player.tsx new file mode 100644 index 0000000..ef56d0b --- /dev/null +++ b/src/components/video/Mp4Player.tsx @@ -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 | ((prev: State) => Partial) + +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((props, ref) => { + const [tcPlayer, setTcPlayer] = useState(null) + const [prevUrl, setPrevUrl] = useState(); + const [state, _setState] = useState({ + 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
+
+}) \ No newline at end of file diff --git a/src/components/video/VideoPlayer.ts b/src/components/video/VideoPlayer.ts new file mode 100644 index 0000000..dbde862 --- /dev/null +++ b/src/components/video/VideoPlayer.ts @@ -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; \ No newline at end of file diff --git a/src/components/video/player.tsx b/src/components/video/player.tsx index 6a9d1f5..4304228 100644 --- a/src/components/video/player.tsx +++ b/src/components/video/player.tsx @@ -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((props, ref) => { - const [tcPlayer, setTcPlayer] = useState(null) const [prevUrl, setPrevUrl] = useState(); + const [tcPlayer, setTcPlayer] = useState(null); + const [state, _setState] = useState({ playing: false, muted: false, @@ -42,105 +42,85 @@ export const Player = React.forwardRef((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
-
-}) \ No newline at end of file + ; +}); \ No newline at end of file diff --git a/src/components/video/video-list-item.tsx b/src/components/video/video-list-item.tsx index e09c645..78da93f 100644 --- a/src/components/video/video-list-item.tsx +++ b/src/components/video/video-list-item.tsx @@ -89,7 +89,7 @@ export const VideoListItem = ( >{index}
- + {generating &&
diff --git a/src/pages/live-player/index.tsx b/src/pages/live-player/index.tsx index de5f54d..86109e5 100644 --- a/src/pages/live-player/index.tsx +++ b/src/pages/live-player/index.tsx @@ -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' diff --git a/src/pages/video/index.tsx b/src/pages/video/index.tsx index a1625c8..aaa0471 100644 --- a/src/pages/video/index.tsx +++ b/src/pages/video/index.tsx @@ -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); - // } - // }) } }}> diff --git a/src/types/tcplayer.d.ts b/src/types/tcplayer.d.ts new file mode 100644 index 0000000..53686e3 --- /dev/null +++ b/src/types/tcplayer.d.ts @@ -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; +} \ No newline at end of file diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 2836d38..d230add 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -14,6 +14,8 @@ declare const AppConfig: { API_PREFIX: string; ONLY_LIVE: string; APP_LANG: string; + // 腾讯播放器 + TCPlayerLicense: string; }; declare const AppMode: 'test' | 'production' | 'development'; diff --git a/vite.config.ts b/vite.config.ts index 66957b1..b5be5f1 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,6 +4,10 @@ import {resolve} from "path"; import AppPackage from './package.json' import dayjs from "dayjs"; +// 播放器 SDK Web 端(TCPlayer)自 5.0.0 版本起需获取 License 授权后方可使用。 +//

https://cloud.tencent.com/document/product/881/77877#.E5.87.86.E5.A4.87.E5.B7.A5.E4.BD.9C

+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'))