|
|
@@ -0,0 +1,463 @@
|
|
|
+<template>
|
|
|
+ <div class="page-container">
|
|
|
+ <!-- 返回按钮 -->
|
|
|
+ <div class="page-header">
|
|
|
+ <el-button :icon="ArrowLeft" @click="goBack">返回</el-button>
|
|
|
+ <span class="title">{{ isPlayback ? '录像回放' : '实时播放' }} - {{ channelId }}</span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 录像日期选择 -->
|
|
|
+ <div v-if="isPlayback" class="date-picker">
|
|
|
+ <span>选择录像日期:</span>
|
|
|
+ <el-date-picker
|
|
|
+ v-model="queryDate"
|
|
|
+ type="date"
|
|
|
+ placeholder="选择日期"
|
|
|
+ value-format="YYYY-MM-DD"
|
|
|
+ @change="loadDevRecord"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 播放器区域 -->
|
|
|
+ <div class="player-wrapper" v-loading="playerLoading">
|
|
|
+ <div ref="playerContainer" class="player-container"></div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 控制按钮 -->
|
|
|
+ <div class="control-bar">
|
|
|
+ <div class="control-left">
|
|
|
+ <el-button v-if="!playing" type="primary" :icon="VideoPlay" @click="handlePlay">播放</el-button>
|
|
|
+ <el-button v-else type="danger" :icon="VideoPause" @click="handleStop">停止</el-button>
|
|
|
+
|
|
|
+ <el-button v-if="!muted" type="info" :icon="Mute" @click="handleMute">静音</el-button>
|
|
|
+ <el-button v-else type="warning" :icon="Microphone" @click="handleUnmute">放音</el-button>
|
|
|
+
|
|
|
+ <el-slider
|
|
|
+ v-model="volume"
|
|
|
+ :disabled="muted"
|
|
|
+ :max="100"
|
|
|
+ :format-tooltip="(val: number) => `音量: ${val}%`"
|
|
|
+ class="volume-slider"
|
|
|
+ @change="handleVolumeChange"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="control-right">
|
|
|
+ <el-button :icon="Camera" @click="handleScreenshot">截图</el-button>
|
|
|
+ <el-button :icon="FullScreen" @click="handleFullscreen">全屏</el-button>
|
|
|
+
|
|
|
+ <template v-if="isPlayback && playing">
|
|
|
+ <el-button v-if="!paused" type="primary" :icon="VideoPause" @click="handlePause">暂停</el-button>
|
|
|
+ <el-button v-else type="success" :icon="VideoPlay" @click="handleResume">恢复</el-button>
|
|
|
+ </template>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 云台控制(仅实时播放) -->
|
|
|
+ <div v-if="!isPlayback" class="ptz-control">
|
|
|
+ <h4>云台控制</h4>
|
|
|
+ <div class="ptz-grid">
|
|
|
+ <div class="ptz-row">
|
|
|
+ <el-button @click="ptzControl('upleft')">↖</el-button>
|
|
|
+ <el-button @click="ptzControl('up')">↑</el-button>
|
|
|
+ <el-button @click="ptzControl('upright')">↗</el-button>
|
|
|
+ </div>
|
|
|
+ <div class="ptz-row">
|
|
|
+ <el-button @click="ptzControl('left')">←</el-button>
|
|
|
+ <el-button type="danger" @click="ptzControl('stop')">停</el-button>
|
|
|
+ <el-button @click="ptzControl('right')">→</el-button>
|
|
|
+ </div>
|
|
|
+ <div class="ptz-row">
|
|
|
+ <el-button @click="ptzControl('downleft')">↙</el-button>
|
|
|
+ <el-button @click="ptzControl('down')">↓</el-button>
|
|
|
+ <el-button @click="ptzControl('downright')">↘</el-button>
|
|
|
+ </div>
|
|
|
+ <div class="ptz-row ptz-zoom">
|
|
|
+ <el-button @click="ptzControl('zoomin')">放大</el-button>
|
|
|
+ <el-button @click="ptzControl('zoomout')">缩小</el-button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup lang="ts">
|
|
|
+import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
|
|
+import { useRoute, useRouter } from 'vue-router'
|
|
|
+import { ElMessage } from 'element-plus'
|
|
|
+import {
|
|
|
+ ArrowLeft,
|
|
|
+ VideoPlay,
|
|
|
+ VideoPause,
|
|
|
+ Mute,
|
|
|
+ Microphone,
|
|
|
+ Camera,
|
|
|
+ FullScreen
|
|
|
+} from '@element-plus/icons-vue'
|
|
|
+import {
|
|
|
+ play,
|
|
|
+ stopPlay,
|
|
|
+ playback,
|
|
|
+ playbackStop,
|
|
|
+ playbackPause,
|
|
|
+ playbackReplay,
|
|
|
+ getDevRecord,
|
|
|
+ ptzControl as ptzControlApi
|
|
|
+} from '@/api/camera'
|
|
|
+
|
|
|
+declare global {
|
|
|
+ interface Window {
|
|
|
+ Jessibuca: any
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const route = useRoute()
|
|
|
+const router = useRouter()
|
|
|
+
|
|
|
+const deviceId = route.params.deviceId as string
|
|
|
+const channelId = route.params.channelId as string
|
|
|
+const isPlayback = computed(() => route.query.mode === 'playback')
|
|
|
+
|
|
|
+const playerContainer = ref<HTMLElement>()
|
|
|
+const playerLoading = ref(false)
|
|
|
+const playing = ref(false)
|
|
|
+const muted = ref(true)
|
|
|
+const paused = ref(false)
|
|
|
+const volume = ref(100)
|
|
|
+const queryDate = ref('')
|
|
|
+
|
|
|
+let jessibuca: any = null
|
|
|
+let streamInfo = {
|
|
|
+ ssrc: '',
|
|
|
+ flv: ''
|
|
|
+}
|
|
|
+
|
|
|
+function initPlayer() {
|
|
|
+ if (!playerContainer.value) return
|
|
|
+
|
|
|
+ jessibuca = new window.Jessibuca({
|
|
|
+ container: playerContainer.value,
|
|
|
+ videoBuffer: 0.2,
|
|
|
+ decoder: '/js/jessibuca/decoder.js',
|
|
|
+ timeout: 20,
|
|
|
+ debug: false,
|
|
|
+ isResize: false,
|
|
|
+ loadingText: '加载中...',
|
|
|
+ isFlv: true,
|
|
|
+ showBandwidth: true,
|
|
|
+ supportDblclickFullscreen: true,
|
|
|
+ operateBtns: {
|
|
|
+ fullscreen: true,
|
|
|
+ screenshot: false,
|
|
|
+ play: false,
|
|
|
+ audio: false
|
|
|
+ },
|
|
|
+ forceNoOffscreen: true,
|
|
|
+ isNotMute: false
|
|
|
+ })
|
|
|
+
|
|
|
+ jessibuca.on('error', (error: any) => {
|
|
|
+ console.error('Player error:', error)
|
|
|
+ destroyPlayer()
|
|
|
+ })
|
|
|
+
|
|
|
+ jessibuca.on('timeout', () => {
|
|
|
+ console.log('Player timeout')
|
|
|
+ destroyPlayer()
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+function destroyPlayer() {
|
|
|
+ if (jessibuca) {
|
|
|
+ jessibuca.destroy()
|
|
|
+ jessibuca = null
|
|
|
+ }
|
|
|
+ initPlayer()
|
|
|
+}
|
|
|
+
|
|
|
+async function handlePlay() {
|
|
|
+ if (isPlayback.value) {
|
|
|
+ loadDevRecord()
|
|
|
+ } else {
|
|
|
+ playLive()
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function playLive() {
|
|
|
+ playerLoading.value = true
|
|
|
+ try {
|
|
|
+ const res = await play(deviceId, channelId)
|
|
|
+ if (res.code === 200 && res.data) {
|
|
|
+ streamInfo.ssrc = res.data.streamId
|
|
|
+ streamInfo.flv = res.data.flv
|
|
|
+ playing.value = true
|
|
|
+
|
|
|
+ if (jessibuca && streamInfo.flv) {
|
|
|
+ jessibuca.play(streamInfo.flv)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ console.error('播放失败', error)
|
|
|
+ } finally {
|
|
|
+ playerLoading.value = false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function loadDevRecord() {
|
|
|
+ if (!queryDate.value) {
|
|
|
+ const today = new Date()
|
|
|
+ queryDate.value = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`
|
|
|
+ }
|
|
|
+
|
|
|
+ playerLoading.value = true
|
|
|
+ try {
|
|
|
+ const date = new Date(queryDate.value).getTime()
|
|
|
+ const start = Math.floor(date / 1000)
|
|
|
+ const end = Math.floor((date + 24 * 60 * 60 * 1000 - 1) / 1000)
|
|
|
+
|
|
|
+ const res = await getDevRecord(deviceId, channelId, { start, end })
|
|
|
+
|
|
|
+ if (res.code === 200 && res.data?.recordItems && res.data.recordItems.length > 0) {
|
|
|
+ const records = res.data.recordItems
|
|
|
+ const firstRecord = records[0]
|
|
|
+ if (firstRecord) {
|
|
|
+ await playRecordback(firstRecord.start, end)
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ ElMessage.warning('当前通道没有录像')
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ console.error('加载录像失败', error)
|
|
|
+ } finally {
|
|
|
+ playerLoading.value = false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function playRecordback(start: number, end: number) {
|
|
|
+ try {
|
|
|
+ const res = await playback(deviceId, channelId, { start, end })
|
|
|
+ if (res.code === 200 && res.data) {
|
|
|
+ streamInfo.ssrc = res.data.streamId
|
|
|
+ streamInfo.flv = res.data.flv
|
|
|
+ playing.value = true
|
|
|
+
|
|
|
+ if (jessibuca && streamInfo.flv) {
|
|
|
+ jessibuca.play(streamInfo.flv)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ console.error('回放失败', error)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function handleStop() {
|
|
|
+ playerLoading.value = true
|
|
|
+ try {
|
|
|
+ if (isPlayback.value) {
|
|
|
+ await playbackStop(deviceId, channelId)
|
|
|
+ } else {
|
|
|
+ await stopPlay(deviceId, channelId)
|
|
|
+ }
|
|
|
+ playing.value = false
|
|
|
+ paused.value = false
|
|
|
+ streamInfo.ssrc = ''
|
|
|
+ streamInfo.flv = ''
|
|
|
+ destroyPlayer()
|
|
|
+ } finally {
|
|
|
+ playerLoading.value = false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function handlePause() {
|
|
|
+ if (!playing.value || !isPlayback.value) return
|
|
|
+
|
|
|
+ try {
|
|
|
+ const res = await playbackPause(deviceId, channelId)
|
|
|
+ if (res.code === 200) {
|
|
|
+ paused.value = true
|
|
|
+ jessibuca?.pause()
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ console.error('暂停失败', error)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function handleResume() {
|
|
|
+ if (!paused.value || !isPlayback.value) return
|
|
|
+
|
|
|
+ try {
|
|
|
+ const res = await playbackReplay(deviceId, channelId)
|
|
|
+ if (res.code === 200) {
|
|
|
+ paused.value = false
|
|
|
+ jessibuca?.play()
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ console.error('恢复失败', error)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function handleMute() {
|
|
|
+ jessibuca?.mute()
|
|
|
+ muted.value = true
|
|
|
+}
|
|
|
+
|
|
|
+function handleUnmute() {
|
|
|
+ jessibuca?.cancelMute()
|
|
|
+ muted.value = false
|
|
|
+}
|
|
|
+
|
|
|
+function handleVolumeChange(val: number) {
|
|
|
+ jessibuca?.setVolume(val / 100)
|
|
|
+}
|
|
|
+
|
|
|
+function handleScreenshot() {
|
|
|
+ if (playing.value) {
|
|
|
+ jessibuca?.screenshot()
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function handleFullscreen() {
|
|
|
+ if (playing.value) {
|
|
|
+ jessibuca?.setFullscreen(true)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function ptzControl(command: string) {
|
|
|
+ try {
|
|
|
+ await ptzControlApi(deviceId, channelId, command)
|
|
|
+ } catch (error) {
|
|
|
+ console.error('云台控制失败', error)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function goBack() {
|
|
|
+ router.push(`/camera/channel/${deviceId}`)
|
|
|
+}
|
|
|
+
|
|
|
+onMounted(() => {
|
|
|
+ // 等待 Jessibuca 脚本加载
|
|
|
+ const checkJessibuca = () => {
|
|
|
+ if (window.Jessibuca) {
|
|
|
+ initPlayer()
|
|
|
+ } else {
|
|
|
+ setTimeout(checkJessibuca, 100)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ checkJessibuca()
|
|
|
+})
|
|
|
+
|
|
|
+onBeforeUnmount(() => {
|
|
|
+ if (playing.value) {
|
|
|
+ handleStop()
|
|
|
+ }
|
|
|
+ if (jessibuca) {
|
|
|
+ jessibuca.destroy()
|
|
|
+ }
|
|
|
+})
|
|
|
+</script>
|
|
|
+
|
|
|
+<style lang="scss" scoped>
|
|
|
+.page-container {
|
|
|
+ padding: 20px;
|
|
|
+}
|
|
|
+
|
|
|
+.page-header {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ margin-bottom: 20px;
|
|
|
+ padding: 15px 20px;
|
|
|
+ background-color: #fff;
|
|
|
+ border-radius: 4px;
|
|
|
+
|
|
|
+ .title {
|
|
|
+ margin-left: 15px;
|
|
|
+ font-size: 16px;
|
|
|
+ font-weight: 600;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.date-picker {
|
|
|
+ margin-bottom: 20px;
|
|
|
+ padding: 15px 20px;
|
|
|
+ background-color: #fff;
|
|
|
+ border-radius: 4px;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+
|
|
|
+ span {
|
|
|
+ margin-right: 10px;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.player-wrapper {
|
|
|
+ background-color: #000;
|
|
|
+ border-radius: 4px;
|
|
|
+ overflow: hidden;
|
|
|
+
|
|
|
+ .player-container {
|
|
|
+ width: 100%;
|
|
|
+ height: 500px;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.control-bar {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: space-between;
|
|
|
+ margin-top: 20px;
|
|
|
+ padding: 15px 20px;
|
|
|
+ background-color: #fff;
|
|
|
+ border-radius: 4px;
|
|
|
+
|
|
|
+ .control-left,
|
|
|
+ .control-right {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 10px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .volume-slider {
|
|
|
+ width: 100px;
|
|
|
+ margin-left: 10px;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.ptz-control {
|
|
|
+ margin-top: 20px;
|
|
|
+ padding: 20px;
|
|
|
+ background-color: #fff;
|
|
|
+ border-radius: 4px;
|
|
|
+
|
|
|
+ h4 {
|
|
|
+ margin-bottom: 15px;
|
|
|
+ font-size: 14px;
|
|
|
+ color: #303133;
|
|
|
+ }
|
|
|
+
|
|
|
+ .ptz-grid {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+ gap: 5px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .ptz-row {
|
|
|
+ display: flex;
|
|
|
+ gap: 5px;
|
|
|
+
|
|
|
+ .el-button {
|
|
|
+ width: 50px;
|
|
|
+ height: 40px;
|
|
|
+ padding: 0;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .ptz-zoom {
|
|
|
+ margin-top: 10px;
|
|
|
+
|
|
|
+ .el-button {
|
|
|
+ width: 80px;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+</style>
|