|
|
@@ -0,0 +1,402 @@
|
|
|
+<template>
|
|
|
+ <div class="tab-content play-content">
|
|
|
+ <div class="media-drawer-content">
|
|
|
+ <!-- 左侧:视频播放区域 -->
|
|
|
+ <div class="video-area">
|
|
|
+ <div class="video-header">
|
|
|
+ <div class="header-left">
|
|
|
+ <span class="title">{{ currentMediaStream?.name || t('流媒体播放') }}</span>
|
|
|
+ <el-tag v-if="playbackInfo.isLive" type="success" size="small">{{ t('直播中') }}</el-tag>
|
|
|
+ <el-tag v-else type="info" size="small">{{ t('离线') }}</el-tag>
|
|
|
+ </div>
|
|
|
+ <el-button
|
|
|
+ v-if="currentMediaStream && currentMediaStream.status === '1'"
|
|
|
+ type="danger"
|
|
|
+ size="small"
|
|
|
+ :loading="streamStopping"
|
|
|
+ @click="$emit('stopStream')"
|
|
|
+ >
|
|
|
+ {{ t('停止推流') }}
|
|
|
+ </el-button>
|
|
|
+ </div>
|
|
|
+ <div class="player-container">
|
|
|
+ <div v-if="!playbackInfo.videoId" class="player-placeholder">
|
|
|
+ <el-icon :size="80" color="#666">
|
|
|
+ <VideoPlay />
|
|
|
+ </el-icon>
|
|
|
+ <p>{{ t('暂无视频流') }}</p>
|
|
|
+ </div>
|
|
|
+ <VideoPlayer
|
|
|
+ v-else
|
|
|
+ ref="playerRef"
|
|
|
+ player-type="cloudflare"
|
|
|
+ :video-id="playbackInfo.videoId"
|
|
|
+ :customer-domain="playbackInfo.customerDomain"
|
|
|
+ :use-iframe="true"
|
|
|
+ :autoplay="playConfig.autoplay"
|
|
|
+ :muted="playConfig.muted"
|
|
|
+ :controls="true"
|
|
|
+ />
|
|
|
+ <!-- 开始推流按钮 -->
|
|
|
+ <div v-if="currentMediaStream && currentMediaStream.status !== '1'" class="stream-control-overlay">
|
|
|
+ <el-button type="success" size="large" :loading="streamStarting" @click="$emit('startStream')">
|
|
|
+ {{ t('开始推流') }}
|
|
|
+ </el-button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 时间轴操作条 -->
|
|
|
+ <div class="timeline-container">
|
|
|
+ <div class="timeline-header">
|
|
|
+ <span class="timeline-label">{{ t('巡航时间轴') }}</span>
|
|
|
+ <el-select
|
|
|
+ v-model="timelineDurationModel"
|
|
|
+ size="small"
|
|
|
+ style="width: 100px"
|
|
|
+ :disabled="isTimelinePlaying"
|
|
|
+ @change="$emit('durationChange')"
|
|
|
+ >
|
|
|
+ <el-option :value="60" :label="t('1分钟')" />
|
|
|
+ <el-option :value="180" :label="t('3分钟')" />
|
|
|
+ <el-option :value="300" :label="t('5分钟')" />
|
|
|
+ <el-option :value="600" :label="t('10分钟')" />
|
|
|
+ </el-select>
|
|
|
+ <el-button size="small" :disabled="isTimelinePlaying" @click="$emit('addPoint')">
|
|
|
+ + {{ t('添加点') }}
|
|
|
+ </el-button>
|
|
|
+ <el-switch
|
|
|
+ v-model="isLoopEnabledModel"
|
|
|
+ :disabled="isTimelinePlaying"
|
|
|
+ size="small"
|
|
|
+ :active-text="t('循环')"
|
|
|
+ style="margin-left: 8px"
|
|
|
+ />
|
|
|
+ <el-button
|
|
|
+ size="small"
|
|
|
+ type="primary"
|
|
|
+ :loading="isTimelinePlaying"
|
|
|
+ :disabled="!hasActivePoints"
|
|
|
+ @click="$emit('playTimeline')"
|
|
|
+ >
|
|
|
+ <el-icon v-if="!isTimelinePlaying">
|
|
|
+ <VideoPlay />
|
|
|
+ </el-icon>
|
|
|
+ {{ isTimelinePlaying ? t('巡航中...') : t('播放巡航') }}
|
|
|
+ </el-button>
|
|
|
+ <el-button v-if="isTimelinePlaying" size="small" type="danger" @click="$emit('stopTimeline')">
|
|
|
+ {{ t('停止') }}
|
|
|
+ </el-button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 时间轴轨道 -->
|
|
|
+ <div :class="['timeline-track', { 'is-playing': isTimelinePlaying }]" ref="timelineTrackRef">
|
|
|
+ <div class="timeline-progress" :style="{ width: `${timelineProgress}%` }">
|
|
|
+ <div v-if="isTimelinePlaying" class="progress-time">
|
|
|
+ {{ formatTimelineTime(currentPlayTime) }}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div
|
|
|
+ v-for="point in timelinePoints"
|
|
|
+ :key="point.id"
|
|
|
+ :class="[
|
|
|
+ 'timeline-point',
|
|
|
+ {
|
|
|
+ active: point.active,
|
|
|
+ selected: !isTimelinePlaying && selectedPoint?.id === point.id,
|
|
|
+ dragging: draggingPoint?.id === point.id,
|
|
|
+ passed: isTimelinePlaying && point.passed
|
|
|
+ }
|
|
|
+ ]"
|
|
|
+ :style="{ left: `${(point.time / timelineDuration) * 100}%` }"
|
|
|
+ @click.stop="!isTimelinePlaying && $emit('selectPoint', point)"
|
|
|
+ @mousedown.stop="!isTimelinePlaying && $emit('startDragPoint', $event, point)"
|
|
|
+ @contextmenu.prevent="!isTimelinePlaying && $emit('showContextMenu', $event, point)"
|
|
|
+ >
|
|
|
+ <div class="point-number">{{ point.id }}</div>
|
|
|
+ <div v-if="draggingPoint?.id === point.id" class="point-drag-time">
|
|
|
+ {{ formatTimelineTime(point.time) }}
|
|
|
+ </div>
|
|
|
+ <div class="point-tooltip">
|
|
|
+ <div>{{ point.presetName || `Point ${point.id}` }}</div>
|
|
|
+ <div class="point-time">{{ formatTimelineTime(point.time) }}</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 时间刻度 -->
|
|
|
+ <div class="timeline-scale">
|
|
|
+ <span v-for="i in Math.floor(timelineDuration / 60) + 1" :key="i" class="scale-mark">
|
|
|
+ {{ formatTimelineTime((i - 1) * 60) }}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 右键菜单 -->
|
|
|
+ <div
|
|
|
+ v-if="contextMenu.visible"
|
|
|
+ class="timeline-context-menu"
|
|
|
+ :style="{ left: `${contextMenu.x}px`, top: `${contextMenu.y}px` }"
|
|
|
+ @click.stop
|
|
|
+ >
|
|
|
+ <div class="context-menu-item" @click="$emit('contextMenuUpdate')">
|
|
|
+ <el-icon>
|
|
|
+ <Position />
|
|
|
+ </el-icon>
|
|
|
+ {{ t('更新位置') }}
|
|
|
+ </div>
|
|
|
+ <div class="context-menu-item danger" @click="$emit('contextMenuDelete')">
|
|
|
+ <el-icon>
|
|
|
+ <Delete />
|
|
|
+ </el-icon>
|
|
|
+ {{ t('删除') }}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 右侧:PTZ 控制面板 -->
|
|
|
+ <div class="control-panel">
|
|
|
+ <el-collapse v-model="activePanelsModel" class="ptz-collapse">
|
|
|
+ <!-- PTZ 方向控制 -->
|
|
|
+ <el-collapse-item name="ptz">
|
|
|
+ <template #title>
|
|
|
+ <span class="collapse-title">{{ t('PTZ') }}</span>
|
|
|
+ </template>
|
|
|
+ <div class="ptz-grid">
|
|
|
+ <div class="ptz-btn" @mousedown="$emit('ptz', 'UP_LEFT')">
|
|
|
+ <el-icon size="24"><Icon icon="mdi:arrow-top-left" width="24" height="24" /></el-icon>
|
|
|
+ </div>
|
|
|
+ <div class="ptz-btn" @mousedown="$emit('ptz', 'UP')">
|
|
|
+ <el-icon size="24"><Icon icon="mdi:arrow-top" width="24" height="24" /></el-icon>
|
|
|
+ </div>
|
|
|
+ <div class="ptz-btn" @mousedown="$emit('ptz', 'UP_RIGHT')">
|
|
|
+ <el-icon size="24"><Icon icon="mdi:arrow-top-right" width="24" height="24" /></el-icon>
|
|
|
+ </div>
|
|
|
+ <div class="ptz-btn" @mousedown="$emit('ptz', 'LEFT')">
|
|
|
+ <el-icon size="24"><Icon icon="mdi:arrow-left" width="24" height="24" /></el-icon>
|
|
|
+ </div>
|
|
|
+ <div class="ptz-btn ptz-center" @click="$emit('ptzStop')">
|
|
|
+ <el-icon size="24"><Icon icon="mdi:stop" width="24" height="24" /></el-icon>
|
|
|
+ </div>
|
|
|
+ <div class="ptz-btn" @mousedown="$emit('ptz', 'RIGHT')">
|
|
|
+ <el-icon size="24"><Icon icon="mdi:arrow-right" width="24" height="24" /></el-icon>
|
|
|
+ </div>
|
|
|
+ <div class="ptz-btn" @mousedown="$emit('ptz', 'DOWN_LEFT')">
|
|
|
+ <el-icon size="24"><Icon icon="mdi:arrow-left" width="24" height="24" /></el-icon>
|
|
|
+ </div>
|
|
|
+ <div class="ptz-btn" @mousedown="$emit('ptz', 'DOWN')">
|
|
|
+ <el-icon size="24"><Icon icon="mdi:arrow-down" width="24" height="24" /></el-icon>
|
|
|
+ </div>
|
|
|
+ <div class="ptz-btn" @mousedown="$emit('ptz', 'DOWN_RIGHT')">
|
|
|
+ <el-icon size="24"><Icon icon="mdi:arrow-bottom-right" width="24" height="24" /></el-icon>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="zoom-buttons">
|
|
|
+ <el-button size="small" @mousedown="$emit('zoomIn')">
|
|
|
+ <el-icon size="20"><Icon icon="mdi:zoom-in-outline" width="20" height="20" /></el-icon>
|
|
|
+ </el-button>
|
|
|
+ <el-button size="small" @mousedown="$emit('zoomOut')">
|
|
|
+ <el-icon size="20"><Icon icon="mdi:zoom-out-outline" width="20" height="20" /></el-icon>
|
|
|
+ </el-button>
|
|
|
+ <el-button type="primary" size="small" @click="$emit('ptzStop')">
|
|
|
+ <el-icon size="20"><Icon icon="mdi:stop" width="20" height="20" /></el-icon>
|
|
|
+ </el-button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="speed-slider">
|
|
|
+ <span class="label">{{ t('速度') }}</span>
|
|
|
+ <el-slider v-model="ptzSpeedModel" :min="1" :max="100" :step="1" size="small" />
|
|
|
+ <span class="value">{{ ptzSpeed }}</span>
|
|
|
+ </div>
|
|
|
+ </el-collapse-item>
|
|
|
+
|
|
|
+ <!-- 预置位列表 -->
|
|
|
+ <el-collapse-item name="preset">
|
|
|
+ <template #title>
|
|
|
+ <span class="collapse-title">{{ t('预置位') }}</span>
|
|
|
+ </template>
|
|
|
+ <div class="preset-list">
|
|
|
+ <div
|
|
|
+ v-for="preset in localPresetList"
|
|
|
+ :key="preset.id"
|
|
|
+ :class="['preset-item', { active: activePresetId === preset.id }]"
|
|
|
+ >
|
|
|
+ <span class="preset-index">{{ preset.pointId }}</span>
|
|
|
+ <el-input
|
|
|
+ v-if="editingPresetId === preset.id"
|
|
|
+ v-model="editingPresetNameModel"
|
|
|
+ size="small"
|
|
|
+ class="preset-name-input"
|
|
|
+ @blur="$emit('savePresetName', preset)"
|
|
|
+ @keyup.enter="$emit('savePresetName', preset)"
|
|
|
+ @keyup.esc="$emit('cancelEditPresetName')"
|
|
|
+ autofocus
|
|
|
+ />
|
|
|
+ <span v-else class="preset-name" @dblclick="$emit('startEditPresetName', preset)">
|
|
|
+ {{ preset.name }}
|
|
|
+ </span>
|
|
|
+ <div class="preset-actions">
|
|
|
+ <el-tooltip :content="t('跳转')" placement="top">
|
|
|
+ <el-icon class="action-icon" size="20" @click="$emit('gotoLocalPreset', preset)">
|
|
|
+ <Icon icon="mdi:arrow-up-left-bold" width="20" height="20" />
|
|
|
+ </el-icon>
|
|
|
+ </el-tooltip>
|
|
|
+ <el-tooltip :content="t('设置')" placement="top">
|
|
|
+ <el-icon class="action-icon" size="20" @click="$emit('updateLocalPreset', preset)">
|
|
|
+ <Icon icon="mdi:gear" width="20" height="20" />
|
|
|
+ </el-icon>
|
|
|
+ </el-tooltip>
|
|
|
+ <el-tooltip :content="t('删除')" placement="top">
|
|
|
+ <el-icon class="action-icon delete" size="20" @click="$emit('deleteLocalPreset', preset)">
|
|
|
+ <Icon icon="mdi:delete-forever" width="20" height="20" />
|
|
|
+ </el-icon>
|
|
|
+ </el-tooltip>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <el-empty v-if="localPresetList.length === 0" :description="t('暂无预置位')" :image-size="60" />
|
|
|
+ </div>
|
|
|
+ </el-collapse-item>
|
|
|
+
|
|
|
+ <!-- 摄像头信息 -->
|
|
|
+ <el-collapse-item name="camera">
|
|
|
+ <template #title>
|
|
|
+ <span class="collapse-title">{{ t('摄像头信息') }}</span>
|
|
|
+ </template>
|
|
|
+ <div class="camera-info-content" v-loading="capabilitiesLoading">
|
|
|
+ <template v-if="cameraCapabilities">
|
|
|
+ <div class="info-item">
|
|
|
+ <span class="info-label">{{ t('最大预置位') }}:</span>
|
|
|
+ <span class="info-value">{{ cameraCapabilities.maxPresetNum || '-' }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="info-item" v-if="cameraCapabilities.controlProtocol">
|
|
|
+ <span class="info-label">{{ t('控制协议') }}:</span>
|
|
|
+ <span class="info-value">{{ cameraCapabilities.controlProtocol.current }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="info-item" v-if="cameraCapabilities.absoluteZoom">
|
|
|
+ <span class="info-label">{{ t('变焦倍数') }}:</span>
|
|
|
+ <span class="info-value">
|
|
|
+ {{ cameraCapabilities.absoluteZoom.min }}x - {{ cameraCapabilities.absoluteZoom.max }}x
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ <div class="info-item" v-if="cameraCapabilities.support3DPosition !== undefined">
|
|
|
+ <span class="info-label">{{ t('3D定位') }}:</span>
|
|
|
+ <span class="info-value">
|
|
|
+ {{ cameraCapabilities.support3DPosition ? t('支持') : t('不支持') }}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ <div class="info-item" v-if="cameraCapabilities.supportPtzLimits !== undefined">
|
|
|
+ <span class="info-label">{{ t('PTZ限位') }}:</span>
|
|
|
+ <span class="info-value">
|
|
|
+ {{ cameraCapabilities.supportPtzLimits ? t('支持') : t('不支持') }}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ <el-empty
|
|
|
+ v-else-if="!capabilitiesLoading"
|
|
|
+ :description="currentMediaStream?.cameraId ? t('点击刷新加载') : t('请选择直播流')"
|
|
|
+ :image-size="40"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </el-collapse-item>
|
|
|
+ </el-collapse>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup lang="ts">
|
|
|
+import { computed } from 'vue'
|
|
|
+import { useI18n } from 'vue-i18n'
|
|
|
+import { VideoPlay, Position, Delete } from '@element-plus/icons-vue'
|
|
|
+import { Icon } from '@iconify/vue'
|
|
|
+import VideoPlayer from '@/components/VideoPlayer.vue'
|
|
|
+import type { LiveStreamDTO } from '@/types'
|
|
|
+import type { PlaybackInfo, TimelinePoint, PTZCapabilities, LocalPreset } from '../types'
|
|
|
+
|
|
|
+const { t } = useI18n({ useScope: 'global' })
|
|
|
+
|
|
|
+const props = defineProps<{
|
|
|
+ currentMediaStream: LiveStreamDTO | null
|
|
|
+ playbackInfo: PlaybackInfo
|
|
|
+ playConfig: { autoplay: boolean; muted: boolean }
|
|
|
+ playerRef: InstanceType<typeof VideoPlayer> | undefined
|
|
|
+ streamStarting: boolean
|
|
|
+ streamStopping: boolean
|
|
|
+ // Timeline
|
|
|
+ timelineTrackRef: HTMLElement | null
|
|
|
+ timelineDuration: number
|
|
|
+ timelinePoints: TimelinePoint[]
|
|
|
+ selectedPoint: TimelinePoint | null
|
|
|
+ isTimelinePlaying: boolean
|
|
|
+ timelineProgress: number
|
|
|
+ currentPlayTime: number
|
|
|
+ isLoopEnabled: boolean
|
|
|
+ hasActivePoints: boolean
|
|
|
+ draggingPoint: TimelinePoint | null
|
|
|
+ contextMenu: { visible: boolean; x: number; y: number; point: TimelinePoint | null }
|
|
|
+ formatTimelineTime: (seconds: number) => string
|
|
|
+ // PTZ
|
|
|
+ ptzSpeed: number
|
|
|
+ activePanels: string[]
|
|
|
+ localPresetList: LocalPreset[]
|
|
|
+ activePresetId: string | null
|
|
|
+ editingPresetId: string | null
|
|
|
+ editingPresetName: string
|
|
|
+ cameraCapabilities: PTZCapabilities | null
|
|
|
+ capabilitiesLoading: boolean
|
|
|
+}>()
|
|
|
+
|
|
|
+const emit = defineEmits<{
|
|
|
+ stopStream: []
|
|
|
+ startStream: []
|
|
|
+ durationChange: []
|
|
|
+ addPoint: []
|
|
|
+ playTimeline: []
|
|
|
+ stopTimeline: []
|
|
|
+ selectPoint: [point: TimelinePoint]
|
|
|
+ startDragPoint: [e: MouseEvent, point: TimelinePoint]
|
|
|
+ showContextMenu: [e: MouseEvent, point: TimelinePoint]
|
|
|
+ contextMenuUpdate: []
|
|
|
+ contextMenuDelete: []
|
|
|
+ ptz: [direction: string]
|
|
|
+ ptzStop: []
|
|
|
+ zoomIn: []
|
|
|
+ zoomOut: []
|
|
|
+ savePresetName: [preset: LocalPreset]
|
|
|
+ cancelEditPresetName: []
|
|
|
+ startEditPresetName: [preset: LocalPreset]
|
|
|
+ gotoLocalPreset: [preset: LocalPreset]
|
|
|
+ updateLocalPreset: [preset: LocalPreset]
|
|
|
+ deleteLocalPreset: [preset: LocalPreset]
|
|
|
+ 'update:timelineDuration': [value: number]
|
|
|
+ 'update:isLoopEnabled': [value: boolean]
|
|
|
+ 'update:ptzSpeed': [value: number]
|
|
|
+ 'update:activePanels': [value: string[]]
|
|
|
+ 'update:editingPresetName': [value: string]
|
|
|
+}>()
|
|
|
+
|
|
|
+const timelineDurationModel = computed({
|
|
|
+ get: () => props.timelineDuration,
|
|
|
+ set: (v) => emit('update:timelineDuration', v)
|
|
|
+})
|
|
|
+
|
|
|
+const isLoopEnabledModel = computed({
|
|
|
+ get: () => props.isLoopEnabled,
|
|
|
+ set: (v) => emit('update:isLoopEnabled', v)
|
|
|
+})
|
|
|
+
|
|
|
+const ptzSpeedModel = computed({
|
|
|
+ get: () => props.ptzSpeed,
|
|
|
+ set: (v) => emit('update:ptzSpeed', v)
|
|
|
+})
|
|
|
+
|
|
|
+const activePanelsModel = computed({
|
|
|
+ get: () => props.activePanels,
|
|
|
+ set: (v) => emit('update:activePanels', v)
|
|
|
+})
|
|
|
+
|
|
|
+const editingPresetNameModel = computed({
|
|
|
+ get: () => props.editingPresetName,
|
|
|
+ set: (v) => emit('update:editingPresetName', v)
|
|
|
+})
|
|
|
+</script>
|