feat: 优化播放器;添加历史视频

This commit is contained in:
LittleBoy 2024-12-17 19:57:49 +08:00
parent 8e6d7fe702
commit a2770765d8
10 changed files with 132 additions and 130 deletions

View File

@ -160,8 +160,10 @@
} }
.video-player{ .video-player{
.video-js{ .video-js{
@apply w-full h-full;
max-width: 100%; max-width: 100%;
max-height: 100%; max-height: 100%;
background:#fff; // hsl(210, 100%, 48%)
} }
} }

View File

@ -20,6 +20,7 @@ type StateUpdate = Partial<State> | ((prev: State) => Partial<State>)
type Props = { type Props = {
url?: string; cover?: string; showControls?: boolean; className?: string; url?: string; cover?: string; showControls?: boolean; className?: string;
onChange?: (state: State) => void; onChange?: (state: State) => void;
muted?: boolean;
} }
export type PlayerInstance = { export type PlayerInstance = {
play: (url: string, currentTime: number) => void; play: (url: string, currentTime: number) => void;
@ -47,39 +48,52 @@ export const Player = React.forwardRef<PlayerInstance, Props>((props, ref) => {
}) })
} }
useEffect(() => { useEffect(() => {
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)
document.querySelector('.video-player-container-inner').appendChild(playerVideo)
const player = TCPlayer(playerId, {
//sources: [{src: props.url}],
controls: props.showControls,
// muted:props.muted,
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('error', () => {
setState({end: false, playing: false, error: true})
})
setTcPlayer(() => player)
return () => { return () => {
if (tcPlayer) tcPlayer.unload() if (tcPlayer) {
tcPlayer.pause()
tcPlayer.unload()
}
} }
}, []) }, [])
React.useImperativeHandle(ref, () => { React.useImperativeHandle(ref, () => {
return { return {
play: (url, currentTime = 0) => { play: (url, currentTime = 0) => {
const player = tcPlayer || TCPlayer( console.log('play', url, currentTime)
'player-container-id', if (!tcPlayer) return;
{ const player = tcPlayer
sources: [{src: url}], if (prevUrl == url) {
controls: false,
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('error', () => {
setState({end: false, playing: false, error: true})
})
if (!tcPlayer) {
setTcPlayer(() => player)
}
if(prevUrl == url){
player.currentTime(0) player.currentTime(0)
}else{ } else {
player.src(url) player.src(url)
} }
player.play() player.play()
@ -92,9 +106,6 @@ export const Player = React.forwardRef<PlayerInstance, Props>((props, ref) => {
} }
}) })
return <div className={`video-player relative ${props.className}`}> return <div className={`video-player relative ${props.className} video-player-container-inner`}>
<video id="player-container-id" className="" preload="auto"
playsInline webkit-playsinline="true">
</video>
</div> </div>
}) })

View File

@ -3,44 +3,33 @@ import {useSetState} from "ahooks";
import {PlayCircleOutlined} from "@ant-design/icons"; import {PlayCircleOutlined} from "@ant-design/icons";
import {SearchListTimes} from "@/pages/news/components/news-source.ts"; import {SearchListTimes} from "@/pages/news/components/news-source.ts";
type SearchParams = {
keywords?: string;
date?: string;
}
type Props = { type Props = {
onSearch?: (params: SearchParams) => Promise<void>; onSearch?: (params: VideoSearchParams) => void;
onBtnStartClick?: () => Promise<void>; onBtnStartClick?: () => Promise<void>;
loading?:boolean;
} }
export default function SearchForm({onSearch, onBtnStartClick}: Props) { export default function SearchForm({onSearch, onBtnStartClick,loading}: Props) {
const [state, setState] = useSetState<{ const [state, setState] = useSetState<{
timeRange: string; pushing?: boolean;
keywords: string; }>({})
searching: boolean; const onFinish = (values) => {
time: number;
}>({
keywords: "", searching: false, timeRange: "", time: 0
})
const onFinish = (values: any) => {
setState({searching: true})
onSearch?.({ onSearch?.({
keywords: values.keywords, ...values,
date: values.timeRange.join('-'), pagination: {page: 1, limit: 10}
}).finally(() => {
setState({searching: false})
}) })
//console.log(values)
} }
return (<div className={'search-panel'}> return (<div className={'search-panel'}>
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<div className="search-form"> <div className="search-form">
<Form className={""} layout="inline" onFinish={onFinish}> <Form<VideoSearchParams> className={""} layout="inline" onFinish={onFinish} initialValues={{title:'',time_flag:0}}>
<Form.Item name="keywords"> <Form.Item name="title">
<Input className="w-[200px]" placeholder={'请输入搜索信息'}/> <Input className="w-[200px]" allowClear placeholder={'请输入搜索信息'}/>
</Form.Item> </Form.Item>
<Form.Item label={'更新时间'} name="date" className="w-[250px]"> <Form.Item label={'更新时间'} name="time_flag" className="w-[250px]">
<Select <Select
defaultValue={state.time} options={SearchListTimes} options={SearchListTimes}
optionRender={(option) => ( optionRender={(option) => (
<div className="flex items-center"> <div className="flex items-center">
<span role="icon" className={`radio-icon`}></span> <span role="icon" className={`radio-icon`}></span>
@ -49,19 +38,16 @@ export default function SearchForm({onSearch, onBtnStartClick}: Props) {
)} )}
/> />
</Form.Item> </Form.Item>
{/*<Form.Item label={'更新时间'} name="timeRange">*/}
{/* <DatePicker.RangePicker />*/}
{/*</Form.Item>*/}
<Form.Item> <Form.Item>
<Space size={10}> <Space size={10}>
<Button type={'primary'} htmlType={'submit'}></Button> <Button loading={loading} type={'primary'} htmlType={'submit'}></Button>
</Space> </Space>
</Form.Item> </Form.Item>
</Form> </Form>
</div> </div>
<Space size={10}> <Space size={10}>
<Button <Button
loading={state.searching} type={'primary'} loading={state.pushing} type={'primary'}
onClick={onBtnStartClick} icon={<PlayCircleOutlined/>} onClick={onBtnStartClick} icon={<PlayCircleOutlined/>}
></Button> ></Button>
</Space> </Space>

View File

@ -4,6 +4,8 @@ import {useState} from "react";
import ImageCover from './cover.png' import ImageCover from './cover.png'
import {formatDuration, timeFromNow} from "@/util/strings.ts";
import dayjs from "dayjs";
type VideoItemProps = { type VideoItemProps = {
videoInfo: VideoInfo; videoInfo: VideoInfo;
@ -32,13 +34,13 @@ export default function VideoItem(props: VideoItemProps) {
<Image className={'w-full cursor-pointer'} preview={false} src={ImageCover}/> <Image className={'w-full cursor-pointer'} preview={false} src={ImageCover}/>
</div> </div>
<div className="text-sm py-2 px-3"> <div className="text-sm py-2 px-3">
<div className="title my-1 cursor-pointer" onClick={props.onClick}></div> <div className="title my-1 cursor-pointer" onClick={props.onClick}>{props.videoInfo.title}</div>
<div className="info flex justify-between gap-2 text-sm"> <div className="info flex justify-between gap-2 text-sm">
<div className="video-time-info text-gray-500"> <div className="video-time-info text-gray-500">
<span>时长: 2年半</span> <span>: {formatDuration(Math.ceil(props.videoInfo.duration / 1000))}</span>
<span className="ml-1">16</span> <span className="ml-1">{timeFromNow(props.videoInfo.publish_time)}</span>
</div> </div>
{props.onLive && <div className="live-info"> {props.videoInfo.status == 3 && <div className="live-info">
<Tag color="processing" className="mr-0"></Tag> <Tag color="processing" className="mr-0"></Tag>
</div>} </div>}
</div> </div>

View File

@ -1,17 +1,24 @@
import {useState} from "react"; import {useState} from "react";
import {Modal, Pagination} from "antd"; import {Empty, Modal, Pagination} from "antd";
import {useRequest} from "ahooks"; import {useRequest} from "ahooks";
import VideoItem from "@/pages/library/components/video-item.tsx"; import VideoItem from "@/pages/library/components/video-item.tsx";
import SearchForm from "@/pages/library/components/search-form.tsx"; import SearchForm from "@/pages/library/components/search-form.tsx";
import VideoDetail from "@/pages/library/components/video-detail.tsx"; import VideoDetail from "@/pages/library/components/video-detail.tsx";
import {getList} from "@/service/api/video.ts"; import {search} from "@/service/api/video.ts";
export default function LibraryIndex() { export default function LibraryIndex() {
const [modal, contextHolder] = Modal.useModal(); const [modal, contextHolder] = Modal.useModal();
const [checkedIdArray, setCheckedIdArray] = useState<number[]>([]) const [checkedIdArray, setCheckedIdArray] = useState<number[]>([])
const {data} = useRequest(()=>getList(),{ const [params, setParams] = useState<VideoSearchParams>({
time_flag: 0,
pagination: {
page: 1,
limit: 10
}
})
const {data,loading} = useRequest(() => search(params), {
refreshDeps: [params]
}) })
const handleRemove = (video: VideoInfo) => { const handleRemove = (video: VideoInfo) => {
modal.confirm({ modal.confirm({
@ -39,9 +46,9 @@ export default function LibraryIndex() {
{contextHolder} {contextHolder}
<div className="search-form-container mb-5"> <div className="search-form-container mb-5">
<SearchForm <SearchForm
onSearch={async () => { onSearch={setParams}
}}
onBtnStartClick={handleLive} onBtnStartClick={handleLive}
loading={loading}
/> />
</div> </div>
<div className="bg-white rounded p-5"> <div className="bg-white rounded p-5">
@ -61,7 +68,21 @@ export default function LibraryIndex() {
))} ))}
</div> </div>
<div className="video-page-container flex justify-center mt-5"> <div className="video-page-container flex justify-center mt-5">
<Pagination defaultCurrent={1} total={50}/> {data?.pagination && data?.pagination.total > 0 ? <div className="flex justify-center mt-10">
<Pagination
current={params.pagination.page}
total={data?.pagination.total}
pageSize={data?.pagination.limit}
showSizeChanger={false}
simple={true}
rootClassName={'simple-pagination'}
onChange={(page) => setParams(prev=>({...prev,pagination: {page, limit: 10}}))}
/>
</div> : <div className="py-10">
<Empty />
</div>
}
{/*<Pagination defaultCurrent={1} total={50}/>*/}
</div> </div>
</div> </div>
</div> </div>

View File

@ -11,10 +11,13 @@ import ButtonBatch from "@/components/button-batch.tsx";
import FlvJs from "flv.js"; import FlvJs from "flv.js";
import {formatDuration} from "@/util/strings.ts"; import {formatDuration} from "@/util/strings.ts";
import {useSetState} from "ahooks"; import {useSetState} from "ahooks";
import {Player, PlayerInstance} from "@/components/video/player.tsx";
const cache: { flvPlayer?: FlvJs.Player,timerPlayNext?:any,timerLoadState?:any,prevUrl?:string } = {} const cache: { flvPlayer?: FlvJs.Player, timerPlayNext?: any, timerLoadState?: any, prevUrl?: string } = {}
export default function LiveIndex() { export default function LiveIndex() {
const videoRef = useRef<HTMLVideoElement | null>(null) const videoRef = useRef<HTMLVideoElement | null>(null)
const player = useRef<PlayerInstance | null>(null)
const [videoData, setVideoData] = useState<LiveVideoInfo[]>([]) const [videoData, setVideoData] = useState<LiveVideoInfo[]>([])
const [modal, contextHolder] = Modal.useModal() const [modal, contextHolder] = Modal.useModal()
const [checkedIdArray, setCheckedIdArray] = useState<number[]>([]) const [checkedIdArray, setCheckedIdArray] = useState<number[]>([])
@ -25,9 +28,9 @@ export default function LiveIndex() {
muted: true, muted: true,
}) })
const activeIndex = useRef(state.activeIndex) const activeIndex = useRef(state.activeIndex)
useEffect(()=>{ useEffect(() => {
activeIndex.current = state.activeIndex activeIndex.current = state.activeIndex
},[state.activeIndex]) }, [state.activeIndex])
const showVideoItem = (index: number) => { const showVideoItem = (index: number) => {
// 找到对应video item 并显示在视图可见区域 // 找到对应video item 并显示在视图可见区域
@ -51,7 +54,7 @@ export default function LiveIndex() {
const activeToNext = (index?: number) => { const activeToNext = (index?: number) => {
const endToFirst = index != undefined && index > -1 ? false : activeIndex.current >= videoData.length - 1 const endToFirst = index != undefined && index > -1 ? false : activeIndex.current >= videoData.length - 1
const _activeIndex = index != undefined && index > -1 ? index : (endToFirst ? 0 : activeIndex.current + 1) const _activeIndex = index != undefined && index > -1 ? index : (endToFirst ? 0 : activeIndex.current + 1)
setState({activeIndex:_activeIndex}) setState({activeIndex: _activeIndex})
if (endToFirst) { if (endToFirst) {
showToast('即将播放第一条视频'); showToast('即将播放第一条视频');
} }
@ -60,51 +63,25 @@ export default function LiveIndex() {
return _activeIndex; return _activeIndex;
} }
const playVideo = (video: LiveVideoInfo, liveState: LiveState) => { const playVideo = (video: LiveVideoInfo, liveState: LiveState) => {
if (videoRef.current && video.video_oss_url) { if (player.current && video.video_oss_url) {
if(cache.timerPlayNext) clearTimeout(cache.timerPlayNext) if (cache.timerPlayNext) clearTimeout(cache.timerPlayNext)
const duration = Math.ceil(video.video_duration / 1000) const duration = Math.ceil(video.video_duration / 1000)
const playedTime =( Date.now() / 1000 >> 0) - liveState.live_start_time const playedTime = (Date.now() / 1000 >> 0) - liveState.live_start_time
if (playedTime < 0 || playedTime > duration) { // 已播放时间大于总时长了 if (playedTime < 0 || playedTime > duration) { // 已播放时间大于总时长了
//initPlayingState() // 重新获取播放状态 //initPlayingState() // 重新获取播放状态
return; return;
} }
if (/mp4$/i.test(video.video_oss_url)) { player.current?.play(video.video_oss_url, playedTime)
videoRef.current!.src = video.video_oss_url cache.timerPlayNext = setTimeout(() => {
if(liveState.live_start_time > 0 && playedTime > 0) videoRef.current!.currentTime = playedTime const index = activeToNext(), nextVideo = videoData[index]
videoRef.current!.play() playVideo(nextVideo, {live_start_time: (Date.now() / 1000 >> 0), id: nextVideo.id})
return; }, (duration - playedTime) * 1000)
}
if (FlvJs.isSupported()) {
if(cache.prevUrl !== video.video_oss_url) {
// 已经有播放实例 则销毁
if (cache.flvPlayer) {
cache.flvPlayer.pause()
cache.flvPlayer.unload()
}
cache.prevUrl = video.video_oss_url
cache.flvPlayer = FlvJs.createPlayer({
type: 'flv',
url: video.video_oss_url
})
cache.flvPlayer.attachMediaElement(videoRef.current!)
cache.flvPlayer.load()
}
if(liveState.live_start_time > 0 && playedTime > 0) videoRef.current!.currentTime = playedTime
cache.flvPlayer!.play()
cache.timerPlayNext = setTimeout(()=>{
const index = activeToNext(),nextVideo = videoData[index]
playVideo(nextVideo,{live_start_time:(Date.now() / 1000 >> 0),id:nextVideo.id})
},(duration - playedTime) * 1000)
}
} }
} }
const initPlayingState = () => { const initPlayingState = () => {
if(cache.timerLoadState) clearTimeout(cache.timerLoadState) if (cache.timerLoadState) clearTimeout(cache.timerLoadState)
if(videoData.length == 0) { if (videoData.length == 0) {
cache.timerLoadState = setTimeout(initPlayingState, 1000) cache.timerLoadState = setTimeout(initPlayingState, 1000)
return; return;
} }
@ -119,22 +96,22 @@ export default function LiveIndex() {
} }
}); });
} }
const clearAllTimer = ()=>{ const clearAllTimer = () => {
if(cache.timerPlayNext) clearTimeout(cache.timerPlayNext) if (cache.timerPlayNext) clearTimeout(cache.timerPlayNext)
if(cache.timerLoadState) clearTimeout(cache.timerLoadState) if (cache.timerLoadState) clearTimeout(cache.timerLoadState)
} }
const loadList = () => { const loadList = () => {
clearAllTimer(); clearAllTimer();
getList().then(res => { getList().then(res => {
// console.log('origin list', res.list.map(s => s.id)) // console.log('origin list', res.list.map(s => s.id))
setVideoData(()=>(res.list || [])) setVideoData(() => (res.list || []))
setCheckedIdArray([]) setCheckedIdArray([])
}); });
} }
useEffect(initPlayingState,[videoData]) useEffect(initPlayingState, [videoData])
useEffect(()=>{ useEffect(() => {
loadList() loadList()
return clearAllTimer; return clearAllTimer;
}, []) }, [])
@ -186,15 +163,9 @@ export default function LiveIndex() {
<div className="video-player-container mr-8 flex flex-col"> <div className="video-player-container mr-8 flex flex-col">
<div className="text-center text-base"></div> <div className="text-center text-base"></div>
<div className="video-player flex justify-center flex-1 mt-5"> <div className="video-player flex justify-center flex-1 mt-5">
<div className="live-player relative rounded overflow-hidden w-[360px] h-[636px]" style={{backgroundColor:'hsl(210, 100%, 48%)'}}> <div className="live-player relative rounded overflow-hidden w-[360px] h-[636px]"
<video ref={videoRef} autoPlay muted={state.muted} style={{backgroundColor: 'hsl(210, 100%, 48%)'}}>
className="w-[360px] rounded overflow-hidden h-full object-contain"></video> <Player ref={player} className="w-[360px] h-[636px] bg-white" muted={true}/>
{state.muted && state.activeIndex != -1 && <div className="absolute inset-0 flex items-center justify-center">
<Button onClick={()=>{
setState({muted: false})
videoRef.current!.muted= false;
}}></Button>
</div>}
</div> </div>
</div> </div>
<div className="mt-4 text-center text-sm"> <div className="mt-4 text-center text-sm">

View File

@ -19,7 +19,7 @@ export default function VideoIndex() {
const [editId, setEditId] = useState(-1) const [editId, setEditId] = useState(-1)
const [videoData, setVideoData] = useState<VideoInfo[]>([]) const [videoData, setVideoData] = useState<VideoInfo[]>([])
const [modal, contextHolder] = Modal.useModal() const [modal, contextHolder] = Modal.useModal()
const player = useRef<PlayerInstance>(null) const player = useRef<PlayerInstance|null>(null)
const [state, setState] = useSetState({ const [state, setState] = useSetState({
checkedAll: false, checkedAll: false,
playingIndex: -1, playingIndex: -1,
@ -45,9 +45,9 @@ export default function VideoIndex() {
// 播放视频 // 播放视频
const playVideo = (video: VideoInfo, playingIndex: number) => { const playVideo = (video: VideoInfo, playingIndex: number) => {
if (video.oss_video_url && video.status == 2) { if (video.status !== 1) { // video.oss_video_url &&
setState({playingIndex}) setState({playingIndex})
player.current?.play(video.oss_video_url, 0) player.current?.play(video.oss_video_url||'https://staticplus.gachafun.com/ai-collect/composite_video/2024-12-14/1185251497659736064.flv', 0)
} }
} }
// 处理全选 // 处理全选

View File

@ -3,7 +3,9 @@ import {post} from "@/service/request.ts";
export function getList() { export function getList() {
return post<DataList<VideoInfo>>('/video/list') return post<DataList<VideoInfo>>('/video/list')
} }
export function search(params:VideoSearchParams) {
return post<DataList<VideoInfo>>('/video/search',params)
}
/** /**
* *
* @param title * @param title

6
src/types/api.d.ts vendored
View File

@ -80,6 +80,11 @@ declare interface ListCrawlerNewsItem extends BasicArticleInfo {
// 内部文章关联id // 内部文章关联id
internal_article_id: number; internal_article_id: number;
} }
declare interface VideoSearchParams extends ApiRequestPageParams{
// 标题
title?: string;
time_flag?: number;
}
declare interface VideoInfo { declare interface VideoInfo {
id: number; id: number;
video_title: string; video_title: string;
@ -89,6 +94,7 @@ declare interface VideoInfo {
duration: number; duration: number;
article_id: number; article_id: number;
status: number; status: number;
publish_time?: number|string;
} }
// room live // room live
declare interface LiveVideoInfo { declare interface LiveVideoInfo {

View File

@ -61,6 +61,7 @@ export function formatTime(time:any,template = 'YYYY-MM-DD HH:mm:ss') {
} }
export function timeFromNow(time: any) { export function timeFromNow(time: any) {
if(!time) return '';
return getDayjs(time).fromNow(); return getDayjs(time).fromNow();
} }