feat: 回放

This commit is contained in:
JiangQiming 2023-03-06 15:43:15 +08:00
parent d51c37ca36
commit 9d411034f6
6 changed files with 709 additions and 4 deletions

49
src/api/media/playback.ts Normal file
View File

@ -0,0 +1,49 @@
import server from '@/utils/request';
import { LocalStore } from '@/utils/comm';
import { BASE_API_PATH, TOKEN_KEY } from '@/utils/variable';
import { recordsItemType } from '@/views/media/Device/Playback/typings';
export default {
// 开始直播
ptzStart: (deviceId: string, channelId: string, type: string) =>
`${BASE_API_PATH}/media/device/${deviceId}/${channelId}/live.${type}?:X_Access_Token=${LocalStore.get(TOKEN_KEY)}`,
// 查询设备通道详情
queryDetail: (deviceId: string, data: any) => server.post(`/media/device/${deviceId}/channel/_query`, data),
// 查询本地回放记录
queryRecordLocal: (deviceId: string, channelId: string, data?: any) =>
server.post<any>(`/media/device/${deviceId}/${channelId}/records/in-local`, data),
// 下载到云端
downloadRecord: (deviceId: string, channelId: string, data: any) =>
server.post(`/media/device/${deviceId}/${channelId}/_record`, data),
// 播放本地回放
playbackLocal: (
deviceId: string,
channelId: string,
suffix: string,
startTime: string,
endTime: string,
speed: number = 1
) =>
`${BASE_API_PATH}/media/device/${deviceId}/${channelId}/playback.${suffix}?:X_Access_Token=${LocalStore.get(TOKEN_KEY)}&startTime=${startTime}&endTime=${endTime}&speed=${speed}`,
// 本地录像播放控制
playbackControl: (deviceId: string, channelId: string) =>
server.post(`/media/device/${deviceId}/${channelId}/stream-control`),
// 查询云端回放记录
recordsInServer: (deviceId: string, channelId: string, data: any) =>
server.post<recordsItemType[]>(`/media/device/${deviceId}/${channelId}/records/in-server`, data),
// 查询云端回放文件信息
recordsInServerFiles: (deviceId: string, channelId: string, data: any) =>
server.post<recordsItemType[]>(`/media/device/${deviceId}/${channelId}/records/in-server/files`, data),
// 播放云端回放
playbackStart: (recordId: string) => `${BASE_API_PATH}/record/${recordId}.mp4?:X_Access_Token=${LocalStore.get(TOKEN_KEY)}`,
downLoadFile: (recordId: string) => `${BASE_API_PATH}/record/${recordId}.mp4?download=true&:X_Access_Token=${LocalStore.get(TOKEN_KEY)}`
}

View File

@ -73,7 +73,9 @@ const iconKeys = [
'BellOutlined',
'UserOutlined',
'LogoutOutlined',
'ReadIconOutlined'
'ReadIconOutlined',
'CloudDownloadOutlined',
'PauseCircleOutlined',
]
const Icon = (props: {type: string}) => {

View File

@ -0,0 +1,76 @@
<!-- 视频图标组件 -->
<template>
<a @click="handleClick">
<AIcon :type="iconType" />
</a>
</template>
<script setup lang="ts">
import type { recordsItemType } from './typings';
import playBackApi from '@/api/media/playback';
import { message } from 'ant-design-vue';
interface Props {
type: string;
item: recordsItemType;
onCloudView: (startTime: number, endTime: number) => void;
onDownLoad: () => void;
}
const props = defineProps<Props>();
// type local0 12
// const status = ref(props.item?.isServer ? 2 : 0);
const status = computed({
get: () => (props.item?.isServer ? 2 : 0),
set: (val: number) => {},
});
const getLocalIcon = (s: number) => {
if (s === 0) {
return 'CloudDownloadOutlined';
} else if (s === 2) {
return 'EyeOutlined';
} else {
return 'LoadingOutlined';
}
};
const iconType = computed(() =>
props.type === 'local' ? getLocalIcon(status.value) : 'DownloadOutlined',
);
const downLoadCloud = (item: recordsItemType) => {
status.value = 1;
playBackApi
.downloadRecord(item.deviceId, item.channelId, {
local: false,
downloadSpeed: 4,
startTime: item.startTime,
endTime: item.endTime,
})
.then((res) => {
if (res.status === 200) {
message.success(
'操作成功。上传云端需要一定时间,请稍后查看云端数据',
);
}
status.value = res.status === 200 ? 2 : 0;
});
};
const handleClick = () => {
if (props.type === 'local') {
if (status.value === 2) {
//
props.onCloudView(props.item.startTime, props.item.endTime);
} else if (status.value === 0) {
//
downLoadCloud(props.item);
}
} else {
props.onDownLoad();
}
};
</script>
<style lang="less" scoped></style>

View File

@ -0,0 +1,120 @@
@borderColor: #d9d9d9;
.playback-warp {
display: flex;
padding: 24px;
background-color: #fff;
.playback-left {
display: flex;
flex-direction: column;
flex-grow: 1;
width: 0;
.playback-media {
width: 100%;
}
}
.playback-right {
width: 300px;
margin-left: 24px;
.playback-calendar {
margin-top: 16px;
border: 1px solid @borderColor;
border-radius: 2px;
.ant-picker-calendar-header {
justify-content: space-between;
> div:nth-child(3) {
display: none;
}
}
}
.playback-list {
display: flex;
height: 300px;
margin-top: 16px;
overflow-y: auto;
border: 1px solid @borderColor;
&.no-list {
align-items: center;
justify-content: center;
}
.playback-list-items {
width: 100%;
.ant-list-item {
padding-left: 12px;
}
}
}
}
.time-line-warp {
padding: 10px 0;
.time-line-clock {
display: flex;
align-items: stretch;
justify-content: space-between;
width: 100%;
> div {
color: #666;
font-size: 12px;
}
}
.time-line-content {
position: relative;
padding-bottom: 20px;
.time-line-progress {
position: relative;
height: 16px;
overflow: hidden;
background-color: #d9d9d9;
border-radius: 2px;
> div {
position: absolute;
top: 0;
left: 0;
height: 100%;
background-color: #52c41a;
cursor: pointer;
}
}
.time-line-btn {
position: absolute;
top: -2px;
left: 0;
width: 3px;
height: 19px;
background-color: @primary-color;
border-radius: 2px;
visibility: hidden;
}
.time-line {
position: absolute;
bottom: -8px;
left: -30px;
width: 60px;
padding: 2px 0;
font-size: 12px;
text-align: center;
background-color: #d9d9d9;
border-radius: 2px;
box-shadow: 0 0 12px rgba(#000, 0.15);
visibility: hidden;
}
}
}
}

View File

@ -1,7 +1,203 @@
<!-- 回放 -->
<template>
<page-container> 回放 </page-container>
<page-container>
<div class="playback-warp">
<div class="playback-left">
<LivePlayer :src="url" />
<TimeLine
ref="playTimeNode"
:type="type"
:data="historyList"
:date-time="time"
:on-change="handleTimeLineChange"
:play-status="playStatus"
:play-time="playNowTime + playTime * 1000"
:local-to-server="cloudTime"
/>
</div>
<div class="playback-right">
<a-spin :spinning="loading">
<a-tooltip title="">
</a-tooltip>
</a-spin>
</div>
</div>
</page-container>
</template>
<script setup lang="ts"></script>
<script setup lang="ts">
import playBackApi from '@/api/media/playback';
import type { Moment } from 'moment';
import moment from 'moment';
import TimeLine from './timeLine.vue';
import IconNode from './iconNode.vue';
import type { recordsItemType } from './typings';
import LivePlayer from '@/components/Player/index.vue';
<style lang="less" scoped></style>
const route = useRoute();
const url = ref('');
const type = ref('local');
const historyList = ref<recordsItemType[]>([]);
const time = ref<Moment | undefined>(undefined);
const loading = ref(false);
const cloudTime = ref<any>();
const location = ref({ search: '' });
const player = ref<any>();
const playStatus = ref(0); // , 0 1 2 , 3
const playTime = ref(0);
const playNowTime = ref(0); //
const playTimeNode = ref<any>(null);
const isEnded = ref(false); //
const param = new URLSearchParams(location.value.search);
const deviceId = computed(() => route.params.id as string);
const channelId = computed(() => route.params.channelId as string);
const deviceType = ref('');
const queryLocalRecords = async (date: Moment) => {
playStatus.value = 0;
url.value = '';
if (deviceId.value && channelId.value && date) {
loading.value = true;
const params = {
startTime: date.format('YYYY-MM-DD 00:00:00'),
endTime: date.format('YYYY-MM-DD 23:59:59'),
};
const localResp = await playBackApi.queryRecordLocal(
deviceId.value,
channelId.value,
params,
);
if (localResp.status === 200 && localResp.result.length) {
const serviceResp = await playBackApi.recordsInServer(
deviceId.value,
channelId.value,
{
...params,
includeFiles: false,
},
);
loading.value = false;
let newList: recordsItemType[] = serviceResp.result;
// console.log(newList)
if (serviceResp.status === 200 && serviceResp.result) {
//
newList = localResp.result.map((item: recordsItemType) => {
return {
...item,
isServer: serviceResp.result.length
? serviceResp.result.some(
(serverFile: any) =>
item.startTime <=
serverFile.streamStartTime &&
serverFile.streamEndTime <= item.endTime,
)
: false,
};
});
historyList.value = newList;
} else {
historyList.value = newList;
}
} else {
loading.value = false;
historyList.value = [];
}
}
};
/**
* 查询云端视频
* @param date
*/
const queryServiceRecords = async (date: Moment) => {
playStatus.value = 0;
url.value = '';
if (deviceId.value && channelId.value && date) {
loading.value = true;
const params = {
startTime: date.format('YYYY-MM-DD 00:00:00'),
endTime: date.format('YYYY-MM-DD 23:59:59'),
includeFiles: true,
};
const resp = await playBackApi.recordsInServerFiles(
deviceId.value,
channelId.value,
params,
);
loading.value = false;
if (resp.status === 200) {
historyList.value = resp.result;
}
}
};
const cloudView = (startTime: number, endTime: number) => {
type.value = 'cloud';
cloudTime.value = { startTime, endTime };
queryServiceRecords(time.value!);
};
const downloadClick = async (item: recordsItemType) => {
const downloadUrl = playBackApi.downLoadFile(item.id);
const downNode = document.createElement('a');
downNode.href = downloadUrl;
downNode.download = `${channelId}-${moment(item.startTime).format(
'YYYY-MM-DD-HH-mm-ss',
)}.mp4`;
downNode.style.display = 'none';
document.body.appendChild(downNode);
downNode.click();
document.body.removeChild(downNode);
};
watch(
() => location.value,
(val: any) => {
const _param = new URLSearchParams(val?.search);
const _type = _param.get('type');
if (_type) {
deviceType.value = _type;
const _timeStr = moment(new Date());
time.value = _timeStr;
if (_type === 'fixed-media') {
type.value = 'cloud';
queryServiceRecords(_timeStr);
} else {
queryLocalRecords(_timeStr);
type.value = 'local';
}
}
},
);
const handleTimeLineChange = (times: any) => {
if (times) {
playNowTime.value = Number(times.startTime.valueOf());
playTime.value = 0;
url.value =
type.value === 'local'
? playBackApi.playbackLocal(
times.deviceId,
times.channelId,
'mp4',
moment(times.startTime).format('YYYY-MM-DD HH:mm:ss'),
moment(times.endTime).format('YYYY-MM-DD HH:mm:ss'),
)
: playBackApi.playbackStart(times.deviceId);
} else {
url.value = '';
}
};
</script>
<style lang="less" scoped>
@import './index.less';
</style>

View File

@ -0,0 +1,262 @@
<!-- 播放器时间刻度 -->
<template>
<div class="time-line-warp">
<div class="time-line-clock">
<div
v-for="item in Array.from(Array(25), (v, k) => k)"
:key="item"
style="width: 12px"
>
{{ item }}
</div>
</div>
<div class="time-line-content" ref="LineContent">
<div class="time-line-progress">
<div
v-for="(item, index) in list"
:key="`time_${index}`"
@click="handleProgress($event, item)"
:style="
getLineItemStyle(
item.startTime || item.mediaStartTime,
item.endTime || item.mediaEndTime,
)
"
></div>
</div>
<div id="btn" class="time-line-btn"></div>
<div id="time" class="time-line">
{{ moment(playTime || 0).format('HH:mm:ss') }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { message } from 'ant-design-vue';
import type { Moment } from 'moment';
import moment from 'moment';
import type { recordsItemType } from './typings';
export type TimeChangeType = {
endTime: Moment;
startTime: Moment;
deviceId: string;
channelId: string;
};
interface Props {
onChange: (times: TimeChangeType | undefined) => void;
data: recordsItemType[];
dateTime?: Moment;
type: string;
playStatus: number;
playTime: number;
server?: any;
localToServer?: {
endTime: number;
startTime: number;
};
getPlayList?: (data: any) => void;
}
const props = defineProps<Props>();
//
const startT = ref<number>(
new Date(
moment(props.dateTime).startOf('day').format('YYYY-MM-DD HH:mm:ss'),
).getTime(),
);
//
const endT = ref<number>(
new Date(
moment(props.dateTime).endOf('day').format('YYYY-MM-DD HH:mm:ss'),
).getTime(),
);
const list = ref<any[]>([]);
const playTime = ref<number>(0);
const LineContent = ref<HTMLDivElement>();
// const LineContentSize = LineContent.value;
const LineContentSize = ref({ width: 100 });
const setTimeAndPosition = (ob: number) => {
const oBtn = document.getElementById('btn');
const oTime = document.getElementById('time');
if (oBtn && oTime && LineContentSize.value.width) {
oBtn.style.visibility = 'visible';
oBtn.style.left = `${ob * LineContentSize.value.width}px`;
oTime.style.visibility = 'visible';
oTime.style.left = `${ob * LineContentSize.value.width - 15}px`;
}
};
watch(
() => props.dateTime,
(val: any) => {
startT.value = new Date(
moment(val).startOf('day').format('YYYY-MM-DD HH:mm:ss'),
).getTime();
},
);
const onChange = (
startTime: number,
endTime: number,
deviceId: string,
channelId: string,
) => {
playTime.value = startTime;
props.onChange({
startTime: moment(startTime),
endTime: moment(endTime),
deviceId,
channelId,
});
};
const playByStartTime = (time: any) => {
const playNow = props.data.find((item) => {
const startTime = item.startTime || item.mediaStartTime;
return startTime === time;
});
if (playNow) {
const startTime = playNow.startTime || playNow.mediaStartTime;
const endTime = playNow.endTime || playNow.mediaEndTime;
const deviceId = props.type === 'local' ? playNow.deviceId : playNow.id;
onChange(startTime, endTime, deviceId, playNow.channelId);
}
};
playByStartTime(0);
const onNextPlay = () => {
if (playTime.value) {
//
const nowIndex = props.data.findIndex((item) => {
const startTime = item.startTime || item.mediaStartTime;
return startTime === playTime.value;
});
//
if (nowIndex !== props.data.length - 1) {
const nextPlay = props.data[nowIndex + 1];
const startTime = nextPlay.startTime || nextPlay.mediaStartTime;
const endTime = nextPlay.endTime || nextPlay.mediaEndTime;
const deviceId =
props.type === 'local' ? nextPlay.deviceId : nextPlay.id;
onChange(startTime, endTime, deviceId, nextPlay.channelId);
}
}
};
onNextPlay();
watch(
() => props.data,
(val: any) => {
const { data, localToServer, type } = props;
if (data && Array.isArray(data) && data.length > 0) {
list.value = [...data];
if (type === 'local') {
//
onChange(
data[0].startTime,
data[0].endTime,
data[0].deviceId,
data[0].channelId,
);
} else if (type === 'cloud') {
//
if (localToServer && Object.keys(localToServer).length > 0) {
//
const playItem = data.find((item) => {
return (
item.mediaEndTime <= localToServer.endTime &&
item.mediaStartTime >= localToServer.startTime
);
});
if (playItem) {
//
onChange(
playItem.mediaStartTime,
playItem.mediaEndTime,
playItem.id,
playItem.channelId,
);
} else {
props.onChange(undefined);
message.error('没有可播放的视频资源');
}
} else {
onChange(
data[0].mediaStartTime,
data[0].mediaEndTime,
data[0].id,
data[0].channelId,
);
}
}
} else if (localToServer && localToServer.startTime) {
//
props.onChange(undefined);
message.error('没有可播放的视频资源');
list.value = [];
} else {
//
list.value = [];
props.onChange(undefined);
}
},
);
const getLineItemStyle = (
startTime: number,
endTime: number,
): { left: string; width: string } => {
const start = startTime - startT.value > 0 ? startTime - startT.value : 0;
const _width = LineContentSize.value.width!;
const itemWidth = ((endTime - startTime) / (24 * 3600000)) * _width;
return {
left: `${(start / (24 * 3600000)) * _width}px`,
width: `${itemWidth < 1 ? 1 : itemWidth}px`,
};
};
const playTimeChange = () => {
if (
props.playTime &&
props.playTime >= startT.value &&
props.playTime <= endT.value &&
props.data &&
props.data.length
) {
setTimeAndPosition((props.playTime - startT.value) / 3600000 / 24);
}
};
watch(
() => props.playTime,
() => {
playTimeChange();
},
);
watch(
() => startT.value,
() => {
playTimeChange();
},
);
const handleProgress = (event: any, item: any) => {
const pos = LineContent.value?.getBoundingClientRect();
if (pos && item.endTime) {
const dt = event.clientX - pos.x;
const start = (dt / pos.width) * 24 * 3600000 + startT.value;
const _start = start < item.startTime ? item.startTime : start;
onChange(_start, item.endTime, item.deviceId, item.channelId);
}
};
</script>
<style lang="less" scoped>
@import './index.less';
</style>