|
|
@@ -222,18 +222,209 @@
|
|
|
</el-button>
|
|
|
</template>
|
|
|
</el-dialog>
|
|
|
+
|
|
|
+ <!-- 流媒体播放抽屉 -->
|
|
|
+ <el-drawer
|
|
|
+ v-model="mediaDrawerVisible"
|
|
|
+ direction="rtl"
|
|
|
+ size="50%"
|
|
|
+ :with-header="false"
|
|
|
+ destroy-on-close
|
|
|
+ class="media-drawer"
|
|
|
+ >
|
|
|
+ <div class="media-drawer-content">
|
|
|
+ <!-- 标题 -->
|
|
|
+ <div class="media-drawer-header">
|
|
|
+ <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>
|
|
|
+
|
|
|
+ <!-- 视频播放区域 - 独占一行 -->
|
|
|
+ <div class="player-section">
|
|
|
+ <div v-if="!playbackInfo.videoId" class="player-placeholder">
|
|
|
+ <el-icon :size="60" color="#ddd">
|
|
|
+ <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>
|
|
|
+
|
|
|
+ <!-- 控制区域 -->
|
|
|
+ <div class="control-ptz-container">
|
|
|
+ <!-- 播放控制按钮 -->
|
|
|
+ <div class="control-section">
|
|
|
+ <div class="section-title">{{ t('播放控制') }}</div>
|
|
|
+ <el-space wrap>
|
|
|
+ <el-button type="primary" size="small" @click="handlePlay">{{ t('播放') }}</el-button>
|
|
|
+ <el-button size="small" @click="handlePause">{{ t('暂停') }}</el-button>
|
|
|
+ <el-button type="danger" size="small" @click="handlePlayerStop">{{ t('停止') }}</el-button>
|
|
|
+ <el-button size="small" @click="handleScreenshot">{{ t('截图') }}</el-button>
|
|
|
+ <el-button size="small" @click="handleFullscreen">{{ t('全屏') }}</el-button>
|
|
|
+ <el-divider direction="vertical" />
|
|
|
+ <el-switch v-model="playConfig.muted" :active-text="t('静音')" :inactive-text="t('有声')" />
|
|
|
+ </el-space>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- PTZ 云台控制 -->
|
|
|
+ <div class="ptz-panel">
|
|
|
+ <div class="section-title">{{ t('PTZ 云台控制') }}</div>
|
|
|
+
|
|
|
+ <!-- PTZ 方向控制 九宫格 -->
|
|
|
+ <div class="ptz-controls">
|
|
|
+ <div
|
|
|
+ class="ptz-btn"
|
|
|
+ @mousedown="handlePTZ('UP_LEFT')"
|
|
|
+ @mouseup="handlePTZStop"
|
|
|
+ @mouseleave="handlePTZStop"
|
|
|
+ >
|
|
|
+ <el-icon>
|
|
|
+ <TopLeft />
|
|
|
+ </el-icon>
|
|
|
+ </div>
|
|
|
+ <div class="ptz-btn" @mousedown="handlePTZ('UP')" @mouseup="handlePTZStop" @mouseleave="handlePTZStop">
|
|
|
+ <el-icon>
|
|
|
+ <Top />
|
|
|
+ </el-icon>
|
|
|
+ </div>
|
|
|
+ <div
|
|
|
+ class="ptz-btn"
|
|
|
+ @mousedown="handlePTZ('UP_RIGHT')"
|
|
|
+ @mouseup="handlePTZStop"
|
|
|
+ @mouseleave="handlePTZStop"
|
|
|
+ >
|
|
|
+ <el-icon>
|
|
|
+ <TopRight />
|
|
|
+ </el-icon>
|
|
|
+ </div>
|
|
|
+ <div class="ptz-btn" @mousedown="handlePTZ('LEFT')" @mouseup="handlePTZStop" @mouseleave="handlePTZStop">
|
|
|
+ <el-icon>
|
|
|
+ <Back />
|
|
|
+ </el-icon>
|
|
|
+ </div>
|
|
|
+ <div class="ptz-btn ptz-center" @click="handlePTZStop">
|
|
|
+ <el-icon>
|
|
|
+ <Refresh />
|
|
|
+ </el-icon>
|
|
|
+ </div>
|
|
|
+ <div class="ptz-btn" @mousedown="handlePTZ('RIGHT')" @mouseup="handlePTZStop" @mouseleave="handlePTZStop">
|
|
|
+ <el-icon>
|
|
|
+ <Right />
|
|
|
+ </el-icon>
|
|
|
+ </div>
|
|
|
+ <div
|
|
|
+ class="ptz-btn"
|
|
|
+ @mousedown="handlePTZ('DOWN_LEFT')"
|
|
|
+ @mouseup="handlePTZStop"
|
|
|
+ @mouseleave="handlePTZStop"
|
|
|
+ >
|
|
|
+ <el-icon>
|
|
|
+ <BottomLeft />
|
|
|
+ </el-icon>
|
|
|
+ </div>
|
|
|
+ <div class="ptz-btn" @mousedown="handlePTZ('DOWN')" @mouseup="handlePTZStop" @mouseleave="handlePTZStop">
|
|
|
+ <el-icon>
|
|
|
+ <Bottom />
|
|
|
+ </el-icon>
|
|
|
+ </div>
|
|
|
+ <div
|
|
|
+ class="ptz-btn"
|
|
|
+ @mousedown="handlePTZ('DOWN_RIGHT')"
|
|
|
+ @mouseup="handlePTZStop"
|
|
|
+ @mouseleave="handlePTZStop"
|
|
|
+ >
|
|
|
+ <el-icon>
|
|
|
+ <BottomRight />
|
|
|
+ </el-icon>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 速度控制 -->
|
|
|
+ <div class="speed-control">
|
|
|
+ <div class="control-label">
|
|
|
+ <span>{{ t('速度') }}: {{ ptzSpeed }}</span>
|
|
|
+ </div>
|
|
|
+ <el-slider v-model="ptzSpeed" :min="10" :max="100" :step="10" :show-tooltip="true" />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 缩放控制 -->
|
|
|
+ <div class="zoom-controls">
|
|
|
+ <div class="zoom-header">
|
|
|
+ <el-icon>
|
|
|
+ <ZoomOut />
|
|
|
+ </el-icon>
|
|
|
+ <span>{{ t('缩放') }}</span>
|
|
|
+ <el-icon>
|
|
|
+ <ZoomIn />
|
|
|
+ </el-icon>
|
|
|
+ </div>
|
|
|
+ <el-slider
|
|
|
+ v-model="zoomValue"
|
|
|
+ :min="-100"
|
|
|
+ :max="100"
|
|
|
+ :step="10"
|
|
|
+ :show-tooltip="true"
|
|
|
+ :format-tooltip="formatZoomTooltip"
|
|
|
+ @input="handleZoomChange"
|
|
|
+ @change="handleZoomRelease"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 底部信息 -->
|
|
|
+ <!-- <div class="media-info">
|
|
|
+ <el-descriptions :column="2" size="small" border>
|
|
|
+ <el-descriptions-item :label="t('流水号')">{{ currentMediaStream?.streamSn || '-' }}</el-descriptions-item>
|
|
|
+ <el-descriptions-item :label="t('状态')">{{ getStatusLabel(currentMediaStream?.status)
|
|
|
+ }}</el-descriptions-item>
|
|
|
+ <el-descriptions-item :label="t('摄像头')">{{ currentMediaStream?.cameraId || '-' }}</el-descriptions-item>
|
|
|
+ <el-descriptions-item :label="t('LSS 节点')">{{ currentMediaStream?.lssId || '-' }}</el-descriptions-item>
|
|
|
+ </el-descriptions>
|
|
|
+ </div> -->
|
|
|
+ </div>
|
|
|
+ </el-drawer>
|
|
|
</div>
|
|
|
</template>
|
|
|
|
|
|
<script setup lang="ts">
|
|
|
import { ref, reactive, onMounted, computed, watch } from 'vue'
|
|
|
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
|
|
-import { Search, RefreshRight, Plus, VideoPlay } from '@element-plus/icons-vue'
|
|
|
+import {
|
|
|
+ Search,
|
|
|
+ RefreshRight,
|
|
|
+ Plus,
|
|
|
+ VideoPlay,
|
|
|
+ Top,
|
|
|
+ Bottom,
|
|
|
+ Back,
|
|
|
+ Right,
|
|
|
+ TopLeft,
|
|
|
+ TopRight,
|
|
|
+ BottomLeft,
|
|
|
+ BottomRight,
|
|
|
+ Refresh,
|
|
|
+ ZoomIn,
|
|
|
+ ZoomOut
|
|
|
+} from '@element-plus/icons-vue'
|
|
|
import { listLiveStreams, addLiveStream, updateLiveStream, deleteLiveStream } from '@/api/live-stream'
|
|
|
import { listAllLssNodes } from '@/api/lss'
|
|
|
import { adminListCameras } from '@/api/camera'
|
|
|
import { listAllStreamChannels } from '@/api/stream-channel'
|
|
|
-import { startStreamTask, stopStreamTask } from '@/api/stream-push'
|
|
|
+import { startStreamTask, stopStreamTask, getStreamPlayback } from '@/api/stream-push'
|
|
|
+import VideoPlayer from '@/components/VideoPlayer.vue'
|
|
|
+import { ptzStart, ptzStop } from '@/api/camera'
|
|
|
import type { LiveStreamDTO, LiveStreamStatus, LssNodeDTO, CameraInfoDTO, StreamChannelDTO } from '@/types'
|
|
|
import dayjs from 'dayjs'
|
|
|
import { useI18n } from 'vue-i18n'
|
|
|
@@ -290,6 +481,29 @@ const currentCommandTemplate = ref('')
|
|
|
const currentStreamId = ref<number | null>(null)
|
|
|
const commandUpdateLoading = ref(false)
|
|
|
|
|
|
+// 流媒体播放抽屉
|
|
|
+const mediaDrawerVisible = ref(false)
|
|
|
+const currentMediaStream = ref<LiveStreamDTO | null>(null)
|
|
|
+const playerRef = ref<InstanceType<typeof VideoPlayer>>()
|
|
|
+const playbackInfo = ref<{
|
|
|
+ videoId: string
|
|
|
+ customerDomain: string
|
|
|
+ hlsUrl?: string
|
|
|
+ whepUrl?: string
|
|
|
+ isLive: boolean
|
|
|
+}>({
|
|
|
+ videoId: '',
|
|
|
+ customerDomain: '',
|
|
|
+ isLive: false
|
|
|
+})
|
|
|
+const playConfig = reactive({
|
|
|
+ autoplay: true,
|
|
|
+ muted: true
|
|
|
+})
|
|
|
+// PTZ 控制
|
|
|
+const ptzSpeed = ref(50)
|
|
|
+const zoomValue = ref(0)
|
|
|
+
|
|
|
// 下拉选项
|
|
|
const lssOptions = ref<LssNodeDTO[]>([])
|
|
|
const cameraOptions = ref<CameraInfoDTO[]>([])
|
|
|
@@ -623,6 +837,118 @@ async function handleStopStream(row: LiveStreamDTO) {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+// 查看流媒体
|
|
|
+async function handleViewCloudflare(row: LiveStreamDTO) {
|
|
|
+ currentMediaStream.value = row
|
|
|
+
|
|
|
+ // 尝试获取播放信息
|
|
|
+ if (row.streamSn) {
|
|
|
+ try {
|
|
|
+ const res = await getStreamPlayback(row.streamSn)
|
|
|
+ if (res.success && res.data) {
|
|
|
+ // 从播放信息中提取 Cloudflare 配置
|
|
|
+ playbackInfo.value = {
|
|
|
+ videoId: row.streamSn,
|
|
|
+ customerDomain: 'customer-pj89kn2ke2tcuh19.cloudflarestream.com',
|
|
|
+ hlsUrl: res.data.hlsUrl,
|
|
|
+ whepUrl: res.data.whepUrl,
|
|
|
+ isLive: res.data.isLive
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ console.error('获取播放信息失败', error)
|
|
|
+ // 使用默认配置
|
|
|
+ playbackInfo.value = {
|
|
|
+ videoId: row.streamSn,
|
|
|
+ customerDomain: 'customer-pj89kn2ke2tcuh19.cloudflarestream.com',
|
|
|
+ isLive: false
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ mediaDrawerVisible.value = true
|
|
|
+}
|
|
|
+
|
|
|
+// 播放控制
|
|
|
+function handlePlay() {
|
|
|
+ playerRef.value?.play()
|
|
|
+}
|
|
|
+
|
|
|
+function handlePause() {
|
|
|
+ playerRef.value?.pause()
|
|
|
+}
|
|
|
+
|
|
|
+function handlePlayerStop() {
|
|
|
+ playerRef.value?.stop()
|
|
|
+}
|
|
|
+
|
|
|
+function handleScreenshot() {
|
|
|
+ playerRef.value?.screenshot()
|
|
|
+}
|
|
|
+
|
|
|
+function handleFullscreen() {
|
|
|
+ playerRef.value?.fullscreen()
|
|
|
+}
|
|
|
+
|
|
|
+// PTZ 控制
|
|
|
+async function handlePTZ(direction: string) {
|
|
|
+ if (!currentMediaStream.value?.cameraId) {
|
|
|
+ ElMessage.warning(t('未配置摄像头'))
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ const actionMap: Record<string, string> = {
|
|
|
+ UP: 'up',
|
|
|
+ DOWN: 'down',
|
|
|
+ LEFT: 'left',
|
|
|
+ RIGHT: 'right',
|
|
|
+ UP_LEFT: 'up',
|
|
|
+ UP_RIGHT: 'up',
|
|
|
+ DOWN_LEFT: 'down',
|
|
|
+ DOWN_RIGHT: 'down'
|
|
|
+ }
|
|
|
+ const action = actionMap[direction] || 'stop'
|
|
|
+ await ptzStart(currentMediaStream.value.cameraId, action as any, ptzSpeed.value)
|
|
|
+ } catch (error) {
|
|
|
+ console.error('PTZ 控制失败', error)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function handlePTZStop() {
|
|
|
+ if (!currentMediaStream.value?.cameraId) return
|
|
|
+
|
|
|
+ try {
|
|
|
+ await ptzStop(currentMediaStream.value.cameraId)
|
|
|
+ } catch (error) {
|
|
|
+ console.error('PTZ 停止失败', error)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 缩放控制
|
|
|
+function formatZoomTooltip(val: number) {
|
|
|
+ if (val === 0) return t('停止')
|
|
|
+ return val > 0 ? `${t('放大')} ${val}` : `${t('缩小')} ${Math.abs(val)}`
|
|
|
+}
|
|
|
+
|
|
|
+async function handleZoomChange(val: number) {
|
|
|
+ if (!currentMediaStream.value?.cameraId) return
|
|
|
+
|
|
|
+ if (val === 0) {
|
|
|
+ await ptzStop(currentMediaStream.value.cameraId)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ const action = val > 0 ? 'zoom_in' : 'zoom_out'
|
|
|
+ await ptzStart(currentMediaStream.value.cameraId, action as any, Math.abs(val))
|
|
|
+}
|
|
|
+
|
|
|
+async function handleZoomRelease() {
|
|
|
+ zoomValue.value = 0
|
|
|
+ if (!currentMediaStream.value?.cameraId) return
|
|
|
+ await ptzStop(currentMediaStream.value.cameraId)
|
|
|
+}
|
|
|
+
|
|
|
function handleSizeChange(val: number) {
|
|
|
pageSize.value = val
|
|
|
currentPage.value = 1
|
|
|
@@ -826,4 +1152,176 @@ onMounted(() => {
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
+
|
|
|
+// 流媒体播放抽屉样式
|
|
|
+.media-drawer {
|
|
|
+ :deep(.el-drawer__body) {
|
|
|
+ padding: 0;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ height: 100%;
|
|
|
+ background-color: #f5f7fa;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.media-drawer-content {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ height: 100%;
|
|
|
+ padding: 16px;
|
|
|
+ gap: 16px;
|
|
|
+}
|
|
|
+
|
|
|
+.media-drawer-header {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 12px;
|
|
|
+ padding-bottom: 12px;
|
|
|
+ border-bottom: 1px solid #e5e7eb;
|
|
|
+
|
|
|
+ .title {
|
|
|
+ font-size: 16px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #303133;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.player-section {
|
|
|
+ width: 100%;
|
|
|
+ height: 380px;
|
|
|
+ border-radius: 8px;
|
|
|
+ overflow: hidden;
|
|
|
+ background-color: #000;
|
|
|
+
|
|
|
+ .player-placeholder {
|
|
|
+ height: 100%;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ color: #909399;
|
|
|
+
|
|
|
+ p {
|
|
|
+ margin-top: 15px;
|
|
|
+ font-size: 14px;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.control-ptz-container {
|
|
|
+ display: flex;
|
|
|
+ gap: 16px;
|
|
|
+}
|
|
|
+
|
|
|
+.control-section {
|
|
|
+ flex: 1;
|
|
|
+ background-color: #fff;
|
|
|
+ border-radius: 8px;
|
|
|
+ padding: 16px;
|
|
|
+
|
|
|
+ .section-title {
|
|
|
+ font-size: 14px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #303133;
|
|
|
+ margin-bottom: 12px;
|
|
|
+ padding-bottom: 8px;
|
|
|
+ border-bottom: 1px solid #e5e7eb;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.ptz-panel {
|
|
|
+ width: 200px;
|
|
|
+ background-color: #fff;
|
|
|
+ border-radius: 8px;
|
|
|
+ padding: 16px;
|
|
|
+
|
|
|
+ .section-title {
|
|
|
+ font-size: 14px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #303133;
|
|
|
+ margin-bottom: 12px;
|
|
|
+ padding-bottom: 8px;
|
|
|
+ border-bottom: 1px solid #e5e7eb;
|
|
|
+ }
|
|
|
+
|
|
|
+ .ptz-controls {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: repeat(3, 1fr);
|
|
|
+ gap: 6px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .ptz-btn {
|
|
|
+ aspect-ratio: 1;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ background-color: #f5f7fa;
|
|
|
+ border: 1px solid #dcdfe6;
|
|
|
+ border-radius: 6px;
|
|
|
+ cursor: pointer;
|
|
|
+ transition: all 0.2s;
|
|
|
+ color: #606266;
|
|
|
+
|
|
|
+ &:hover {
|
|
|
+ background-color: #ecf5ff;
|
|
|
+ border-color: #4f46e5;
|
|
|
+ color: #4f46e5;
|
|
|
+ }
|
|
|
+
|
|
|
+ &:active {
|
|
|
+ background-color: #4f46e5;
|
|
|
+ color: #fff;
|
|
|
+ }
|
|
|
+
|
|
|
+ .el-icon {
|
|
|
+ font-size: 18px;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .ptz-center {
|
|
|
+ background-color: #e5e7eb;
|
|
|
+
|
|
|
+ &:hover {
|
|
|
+ background-color: #ecf5ff;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .speed-control {
|
|
|
+ margin-top: 12px;
|
|
|
+ padding-top: 12px;
|
|
|
+ border-top: 1px solid #e5e7eb;
|
|
|
+
|
|
|
+ .control-label {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #909399;
|
|
|
+ margin-bottom: 6px;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .zoom-controls {
|
|
|
+ margin-top: 12px;
|
|
|
+ padding-top: 12px;
|
|
|
+ border-top: 1px solid #e5e7eb;
|
|
|
+
|
|
|
+ .zoom-header {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: space-between;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #909399;
|
|
|
+ margin-bottom: 6px;
|
|
|
+
|
|
|
+ span {
|
|
|
+ flex: 1;
|
|
|
+ text-align: center;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.media-info {
|
|
|
+ background-color: #fff;
|
|
|
+ border-radius: 8px;
|
|
|
+ padding: 16px;
|
|
|
+}
|
|
|
</style>
|