395 lines
15 KiB
TypeScript
395 lines
15 KiB
TypeScript
import React, {useCallback, useEffect, useMemo, useRef, useState} from "react";
|
|
import {Checkbox, Empty, Popconfirm, Space} from "antd";
|
|
import {SortableContext, arrayMove} from '@dnd-kit/sortable';
|
|
import {DndContext} from "@dnd-kit/core";
|
|
import FlvJs from "flv.js";
|
|
import {useTranslation} from "react-i18next";
|
|
import {useSetState} from "ahooks";
|
|
|
|
import {VideoListItem} from "@/components/video/video-list-item.tsx";
|
|
import {deleteByIds, getList, modifyOrder, playState, restoreByIds} from "@/service/api/live.ts";
|
|
import {showErrorToast, showToast} from "@/components/message.ts";
|
|
import ButtonBatch from "@/components/button-batch.tsx";
|
|
import {formatDuration} from "@/util/strings.ts";
|
|
import {Mp4Player as Player, PlayerInstance} from "@/components/video/Mp4Player.tsx";
|
|
import {IconDelete, IconLocked, IconRollbackCircle} from "@/components/icons";
|
|
import InfiniteScroller, {InfiniteScrollerRef} from "@/components/scoller/infinite-scroller.tsx";
|
|
import ButtonToTop from "@/components/scoller/button-to-top.tsx";
|
|
import {ModalWarningIcon, ModalWarningTitle} from "@/components/icons/ModalWarning.tsx";
|
|
|
|
import styles from "./style.module.scss"
|
|
|
|
const cache: { flvPlayer?: FlvJs.Player, timerPlayNext?: any, timerLoadState?: any, prevUrl?: string } = {}
|
|
|
|
export default function LiveIndex() {
|
|
const {t} = useTranslation()
|
|
const player = useRef<PlayerInstance | null>(null)
|
|
|
|
const [videoData, setVideoData] = useState<LiveVideoInfo[]>([])
|
|
const [checkedIdArray, setCheckedIdArray] = useState<number[]>([])
|
|
const [editable, setEditable] = useState<boolean>(false)
|
|
const scrollerRef = useRef<InfiniteScrollerRef | null>(null)
|
|
const [rollbackIds,setRollbackIds] = useState<Id[]>([])
|
|
const [delIds,setDelIds] = useState<Id[]>([])
|
|
|
|
const [state, setState] = useSetState({
|
|
playId:-1,
|
|
muted: true,
|
|
showToTop: false,
|
|
checkedAll: false,
|
|
originSort: '',
|
|
playProgress: 0,
|
|
loading:false
|
|
})
|
|
const activeIndex = useRef(-1)
|
|
useEffect(() => {
|
|
activeIndex.current = videoData.findIndex(s=>s.id == state.playId)
|
|
}, [state.playId,videoData])
|
|
|
|
// 显示当前播放视频对应 view item
|
|
const showVideoItem = (index: number,id: number) => {
|
|
// 找到对应video item 并显示在视图可见区域
|
|
const container = document.querySelector('.live-video-list-sort-container')
|
|
const item = document.querySelector(`.list-item-${id}`)
|
|
if (item && container) {
|
|
// 获取容器数据
|
|
const containerRect = container.getBoundingClientRect()
|
|
// 获取对应item的数据
|
|
const rect = item.getBoundingClientRect()
|
|
// 计算对应item需要在容器中滚动的距离
|
|
const scrollDistance = rect.top - containerRect.top
|
|
// 设置滚动高度
|
|
container.scrollTo({
|
|
top: index == 0 ? 0 : container.scrollTop + scrollDistance - 10,
|
|
behavior: 'smooth'
|
|
})
|
|
}
|
|
}
|
|
// 播放下一个视频
|
|
const activeToNext = useCallback( (index?: number) => {
|
|
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 playVideo = videoData[_activeIndex];
|
|
setState({playId: playVideo.id});
|
|
if (endToFirst) {
|
|
showToast(t("live.play_first"));
|
|
}
|
|
// 找到对应video item 并显示在视图可见区域
|
|
showVideoItem(_activeIndex,playVideo.id)
|
|
return _activeIndex;
|
|
}, [videoData, activeIndex])
|
|
// 播放视频
|
|
const playVideo = (video: LiveVideoInfo, liveState: LiveState) => {
|
|
if (player.current && video.video_oss_url) {
|
|
if (cache.timerPlayNext) clearTimeout(cache.timerPlayNext)
|
|
const duration = Math.ceil(video.video_duration / 1000)
|
|
// 计算已经播放时间
|
|
const playedTime = (Date.now() / 1000 >> 0) - liveState.live_start_time
|
|
if (playedTime < 0 || playedTime > duration) { // 已播放时间大于总时长了
|
|
//initPlayingState() // 重新获取播放状态
|
|
console.log('已播放时间大于总时长')
|
|
cache.timerLoadState = setTimeout(initPlayingState, 5000)
|
|
return;
|
|
}
|
|
player.current?.play(video.video_oss_url, playedTime)
|
|
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 = () => {
|
|
player.current?.pause();
|
|
if (cache.timerLoadState) clearTimeout(cache.timerLoadState)
|
|
if (!videoData || videoData.length == 0) {
|
|
cache.timerLoadState = setTimeout(initPlayingState, 1000)
|
|
return;
|
|
}
|
|
playState().then(liveState => {
|
|
// 获取当前播放视频
|
|
const video = videoData.find(v => v.id === liveState.id)
|
|
if (video) {
|
|
// 开始播放
|
|
activeToNext(videoData.findIndex(v => v.id === liveState.id))
|
|
playVideo(video, liveState)
|
|
} else {
|
|
setState({playId: -1})
|
|
cache.timerLoadState = setTimeout(initPlayingState, 5000)
|
|
}
|
|
});
|
|
}
|
|
const clearAllTimer = () => {
|
|
if (cache.timerPlayNext) clearTimeout(cache.timerPlayNext)
|
|
if (cache.timerLoadState) clearTimeout(cache.timerLoadState)
|
|
}
|
|
|
|
const loadList = () => {
|
|
clearAllTimer();
|
|
setState({loading: true})
|
|
getList().then(res => {
|
|
// console.log('origin list', res.list.map(s => s.id))
|
|
setVideoData(() => (res.list || []))
|
|
setState({
|
|
originSort: res.list ? res.list.map(s => s.id).join(',') : ''
|
|
})
|
|
setCheckedIdArray([])
|
|
}).catch(showErrorToast).finally(()=>{
|
|
setState({loading: false})
|
|
});
|
|
}
|
|
|
|
useEffect(initPlayingState, [videoData])
|
|
useEffect(() => {
|
|
loadList()
|
|
return ()=>{
|
|
clearAllTimer();
|
|
setTimeout(()=>{
|
|
console.log('pause all video')
|
|
try{
|
|
Array.from(document.querySelectorAll('video')).forEach(v => v.pause())
|
|
}catch (e){
|
|
console.log(e)
|
|
}
|
|
},20)
|
|
}
|
|
}, [])
|
|
|
|
// 删除视频
|
|
const processDeleteVideo = async (ids: Id[]) => {
|
|
// 临时记录删除的id
|
|
setDelIds(_=>[...ids,..._])
|
|
// deleteByIds(ids).then(() => {
|
|
// showToast(t('delete_success'), 'success')
|
|
// loadList()
|
|
// }).catch(showErrorToast)
|
|
}
|
|
const resetState = (editable: boolean)=>{
|
|
setEditable(editable)
|
|
setCheckedIdArray([])
|
|
setRollbackIds(()=>[])
|
|
setDelIds(()=>[])
|
|
setState({checkedAll: false})
|
|
}
|
|
// 状态:锁定->解锁
|
|
const handleSetEditable = ()=>{
|
|
resetState(true)
|
|
}
|
|
//
|
|
const handleCancel = ()=>{
|
|
resetState(false)
|
|
}
|
|
const handleRollback = (v:LiveVideoInfo)=>{
|
|
setRollbackIds(_=>[v.id,..._])
|
|
}
|
|
const handleConfirm = async () => {
|
|
if (!editable) {
|
|
return;
|
|
}
|
|
const ids = videoData
|
|
.filter(s=>!(delIds.includes(s.id) || rollbackIds.includes(s.id)))
|
|
.map(s => s.id)
|
|
try{
|
|
// 删除
|
|
if(delIds.length > 0) {
|
|
await deleteByIds(delIds)
|
|
}
|
|
if(rollbackIds.length > 0) {
|
|
await restoreByIds(rollbackIds)
|
|
}
|
|
// 调整排序
|
|
await modifyOrder(ids);
|
|
showToast(t('message.save_success'), 'success')
|
|
}catch (e){
|
|
console.log(e)
|
|
showToast(t('message.save_failed'), 'error')
|
|
}finally {
|
|
loadList()
|
|
resetState(false)
|
|
}
|
|
}
|
|
const handleAllCheckedChange = () => {
|
|
if(!editable) return;
|
|
setCheckedIdArray(state.checkedAll ? [] : videoData.map(v => v.id))
|
|
setState({
|
|
checkedAll: !state.checkedAll
|
|
})
|
|
}
|
|
|
|
// 视频相关时长
|
|
const totalDuration = useMemo(() => {
|
|
if (!videoData || videoData.length == 0) return 0;
|
|
// 计算总时长
|
|
return videoData.reduce((sum, v) => sum + Math.ceil(v.video_duration / 1000), 0);
|
|
}, [videoData])
|
|
const currentVideoDuration = useMemo(()=>{
|
|
const video = videoData.find(s=>s.id == state.playId)
|
|
return (video?.video_duration || 0) / 1000;
|
|
},[state.playId, videoData])
|
|
// // 根据当前播放index计算已经播放时长
|
|
// const currentTotalDuration = useMemo(() => {
|
|
// if (state.activeIndex == -1 || !videoData || videoData.length == 0) return 0;
|
|
// // 计算总时长
|
|
// return videoData
|
|
// .filter((_, index) => (index < state.activeIndex))
|
|
// .reduce((sum, v) => sum + Math.ceil(v.video_duration / 1000), 0) + state.playProgress
|
|
// ;
|
|
// }, [state.activeIndex, state.playProgress, videoData])
|
|
//
|
|
// const currentSelectedId = useMemo(() => {
|
|
// if (state.activeIndex < 0 || state.activeIndex >= videoData.length) return [];
|
|
// const currentId = videoData[state.activeIndex];
|
|
// return checkedIdArray.filter(id => currentId.id != id)
|
|
// }, [checkedIdArray, state.activeIndex])
|
|
|
|
const currentSelectedVideoIds = useMemo(()=>{
|
|
return checkedIdArray.length == 0 ? [] : checkedIdArray.filter(id => id != state.playId)
|
|
},[checkedIdArray, state.playId])
|
|
|
|
return (<div className="container py-5 page-live">
|
|
<div className="h-[36px]"></div>
|
|
<div className="flex">
|
|
<div className="video-player-container mr-16 flex items-center">
|
|
<div>
|
|
<div className="text-center text-base text-gray-400">{formatDuration(totalDuration)}</div>
|
|
<div className="video-player flex justify-center flex-1 mt-1">
|
|
<div className="live-player relative rounded overflow-hidden w-[360px] h-[636px]"
|
|
style={{backgroundColor: 'hsl(210, 100%, 48%)'}}>
|
|
<Player
|
|
ref={player} className="w-[360px] h-[636px] bg-white"
|
|
muted={true}
|
|
onProgress={(progress) => {
|
|
setState({playProgress: progress})
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="text-center text-sm mt-4 text-gray-400">
|
|
<span>{t('live.duration')}: {formatDuration(state.playProgress)} / {formatDuration(currentVideoDuration)}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="video-list-container video-list-sort-container flex flex-col flex-1 mt-2">
|
|
<div className="live-control flex justify-between mb-1 h-[30px]">
|
|
<div>
|
|
<Space>
|
|
{/*<span className={"text-blue-500"}>视频正在播放{state.activeIndex == -1 ? '' : `到 ${state.activeIndex + 1} 条`}</span>*/}
|
|
<span>{t('live.playlist_count',{count:videoData.length})}</span>
|
|
</Space>
|
|
</div>
|
|
|
|
<div className="flex items-center">
|
|
<div className={'flex items-center text-gray-400 cursor-pointer select-none'}>
|
|
{editable ? (<Space size={15}>
|
|
<button className={styles.btnDefault} onClick={handleCancel}>{t('cancel')}</button>
|
|
<button className={styles.btn} onClick={handleConfirm}>{t('save_operation')}</button>
|
|
</Space>):(<div className="flex items-center " onClick={handleSetEditable}>
|
|
{t('live.edit_locked')}
|
|
<span className="ml-2 text-sm"><IconLocked/></span>
|
|
</div>)}
|
|
</div>
|
|
<div className="check-all ml-10">
|
|
<button disabled={editable} className={`${editable?'':'hover:text-blue-300'} text-gray-400`}
|
|
onClick={handleAllCheckedChange}>
|
|
<span className="text-sm mr-2 whitespace-nowrap">{t('select.select_all')}</span>
|
|
{/*<CheckCircleFilled className={clsx({'text-blue-500': state.checkedAll})}/>*/}
|
|
</button>
|
|
<Checkbox disabled={!editable} checked={state.checkedAll} onChange={() => handleAllCheckedChange()}/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="list-header">
|
|
<div className="list-row header-row">
|
|
<div className="col number">No.</div>
|
|
<div className="col cover">{t('video.title_thumb')}</div>
|
|
<div className="col title">{t('video.title')}</div>
|
|
<div className="col generated-time">{t('video.title_generated_time')}</div>
|
|
<div className="col operation">{t('video.title_operation')}</div>
|
|
</div>
|
|
</div>
|
|
<div className="">
|
|
<div className="live-video-list-sort-container ">
|
|
<InfiniteScroller
|
|
ref={scrollerRef}
|
|
loading={state.loading}
|
|
onScroll={top => setState({showToTop: top > 30})}
|
|
onCallback={() => {
|
|
}}
|
|
>
|
|
{videoData.length == 0 && <div className="m-auto py-16"><Empty/></div>}
|
|
<div className="sort-list-container flex-1">
|
|
<DndContext onDragEnd={(e) => {
|
|
const {active, over} = e;
|
|
if (over && active.id !== over.id) {
|
|
let oldIndex = -1, newIndex = -1;
|
|
setVideoData((items) => {
|
|
oldIndex = items.findIndex(s => s.id == active.id);
|
|
newIndex = items.findIndex(s => s.id == over.id);
|
|
return arrayMove(items, oldIndex, newIndex);
|
|
});
|
|
}
|
|
}}>
|
|
<SortableContext items={videoData}>
|
|
{videoData.filter(v=>(!(delIds.includes(v.id) || rollbackIds.includes(v.id)))).map((v, index) => (
|
|
<VideoListItem
|
|
video={v}
|
|
index={index + 1}
|
|
id={v.id}
|
|
key={index}
|
|
active={state.playId == v.id}
|
|
playing={state.playId == v.id}
|
|
className={`list-index-${index} list-item-${v.id} mt-3 mb-2`}
|
|
checked={checkedIdArray.includes(v.id)}
|
|
onCheckedChange={(checked) => {
|
|
const newIdArray = checked ? checkedIdArray.concat(v.id) : checkedIdArray.filter(id => id != v.id);
|
|
setState({checkedAll: newIdArray.length == videoData.length})
|
|
setCheckedIdArray(newIdArray)
|
|
// setCheckedIdArray(idArray => {
|
|
// return checked ? idArray.concat(v.id) : idArray.filter(id => id != v.id);
|
|
// })
|
|
}}
|
|
onRemove={() => processDeleteVideo([v.id])}
|
|
editable={editable && state.playId != v.id}
|
|
sortable={editable && state.playId != v.id}
|
|
additionOperationBefore={<>
|
|
{editable && state.playId != v.id && <Popconfirm
|
|
rootClassName={'popconfirm-main'}
|
|
placement={'left'}
|
|
arrow={false}
|
|
icon={<ModalWarningIcon/>}
|
|
title={<ModalWarningTitle />}
|
|
description={t('video.live_rollback_confirm_title')}
|
|
onConfirm={() => handleRollback(v)}
|
|
><button className="hover:text-blue-500"><IconRollbackCircle /></button></Popconfirm>}
|
|
</>}
|
|
/>))}
|
|
</SortableContext>
|
|
</DndContext>
|
|
</div>
|
|
<div className="h-[100px]"></div>
|
|
</InfiniteScroller>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="page-action">
|
|
<ButtonToTop visible={state.showToTop} onClick={() => scrollerRef.current?.scrollToPosition(0)}/>
|
|
{currentSelectedVideoIds.length > 0 && <ButtonBatch
|
|
className='bg-gray-300 hover:bg-gray-400 text-white'
|
|
selected={currentSelectedVideoIds}
|
|
emptyMessage={t('video.delete_empty')}
|
|
confirmMessage={currentSelectedVideoIds.length > 1?
|
|
t('video.delete_description_count',{count:currentSelectedVideoIds.length})
|
|
:
|
|
t('video.delete_description',{count:currentSelectedVideoIds.length})}
|
|
onSuccess={loadList}
|
|
onProcess={processDeleteVideo}
|
|
>
|
|
<span className={'text'}>{t('delete_batch')}</span>
|
|
<IconDelete/>
|
|
</ButtonBatch>}
|
|
</div>
|
|
</div>)
|
|
} |