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>)
}