ソースを参照

feat: add HLS streaming support and update localization

- Introduced a new HLS streaming page with functionality to input M3U8 URLs and control playback.
- Updated routing to include the new HLS stream component.
- Modified localization strings for better clarity and consistency in the UI.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
yb 2 週間 前
コミット
0a636ce6e1
5 ファイル変更304 行追加56 行削除
  1. 2 1
      src/layout/index.vue
  2. 2 2
      src/locales/en.json
  3. 6 0
      src/router/index.ts
  4. 276 0
      src/views/demo/hls-stream.vue
  5. 18 53
      src/views/stream/config.vue

+ 2 - 1
src/layout/index.vue

@@ -423,7 +423,8 @@ const menuItems: MenuItem[] = [
     children: [
       { path: '/demo/directurl', title: '直接 URL', icon: LinkIcon },
       { path: '/demo/rtsp', title: 'RTSP 流', icon: ConnectionIcon },
-      { path: '/demo/samples', title: '测试视频', icon: FilmIcon }
+      { path: '/demo/samples', title: '测试视频', icon: FilmIcon },
+      { path: '/demo/hls', title: 'M3U8/HLS', icon: StreamIcon }
     ]
   },
   {

+ 2 - 2
src/locales/en.json

@@ -1,5 +1,5 @@
 {
-  "摄像头管理系统": "Camera Management System",
+  "摄像头管理系统": "Camera Management",
   "仪表盘": "Dashboard",
   "机器管理": "Machine Management",
   "摄像头管理": "Camera Management",
@@ -58,4 +58,4 @@
   "版本": "Version",
   "正常": "Normal",
   "获取统计数据失败": "Failed to get statistics"
-}
+}

+ 6 - 0
src/router/index.ts

@@ -133,6 +133,12 @@ const routes: RouteRecordRaw[] = [
         name: 'SampleVideos',
         component: () => import('@/views/demo/sample-videos.vue'),
         meta: { title: '测试视频', icon: 'Film' }
+      },
+      {
+        path: 'demo/hls',
+        name: 'HlsStream',
+        component: () => import('@/views/demo/hls-stream.vue'),
+        meta: { title: 'M3U8/HLS', icon: 'VideoPlay' }
       }
     ]
   },

+ 276 - 0
src/views/demo/hls-stream.vue

@@ -0,0 +1,276 @@
+<template>
+  <div class="page-container">
+    <div class="page-header">
+      <span class="title">M3U8/HLS 播放</span>
+    </div>
+
+    <!-- 配置区域 -->
+    <div class="config-section">
+      <el-form label-width="120px">
+        <el-form-item label="M3U8 地址">
+          <el-input v-model="hlsConfig.url" placeholder="输入 m3u8 地址" style="width: 600px" />
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" @click="playHls">播放</el-button>
+          <el-button @click="hlsConfig.url = ''">清空</el-button>
+        </el-form-item>
+      </el-form>
+    </div>
+
+    <!-- 播放器区域 -->
+    <div class="player-section">
+      <div v-if="!currentSrc" class="player-placeholder">
+        <el-icon :size="60" color="#ddd"><VideoPlay /></el-icon>
+        <p>请输入 M3U8 地址并点击播放</p>
+      </div>
+      <VideoPlayer
+        v-else
+        ref="playerRef"
+        player-type="hls"
+        :src="currentSrc"
+        :autoplay="playConfig.autoplay"
+        :muted="playConfig.muted"
+        :controls="true"
+        @play="onPlay"
+        @pause="onPause"
+        @error="onError"
+      />
+    </div>
+
+    <!-- 播放控制 -->
+    <div class="control-section">
+      <el-space wrap>
+        <el-button type="primary" @click="handlePlay">播放</el-button>
+        <el-button @click="handlePause">暂停</el-button>
+        <el-button type="danger" @click="handleStop">停止</el-button>
+        <el-button @click="handleScreenshot">截图</el-button>
+        <el-button @click="handleFullscreen">全屏</el-button>
+
+        <el-divider direction="vertical" />
+
+        <el-switch v-model="playConfig.muted" active-text="静音" inactive-text="有声" />
+        <el-switch v-model="playConfig.autoplay" active-text="自动播放" inactive-text="手动" />
+      </el-space>
+    </div>
+
+    <!-- 日志区域 -->
+    <div class="log-section">
+      <div class="log-header">
+        <h4>事件日志</h4>
+        <el-button size="small" @click="logs = []">清空</el-button>
+      </div>
+      <div class="log-content">
+        <div v-for="(log, index) in logs" :key="index" class="log-item" :class="log.type">
+          <span class="time">{{ log.time }}</span>
+          <span class="message">{{ log.message }}</span>
+        </div>
+        <div v-if="logs.length === 0" class="log-empty">暂无日志</div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive } from 'vue'
+import { ElMessage } from 'element-plus'
+import { VideoPlay } from '@element-plus/icons-vue'
+import VideoPlayer from '@/components/VideoPlayer.vue'
+
+const playerRef = ref<InstanceType<typeof VideoPlayer>>()
+
+// HLS 配置
+const hlsConfig = reactive({
+  url: 'https://customer-pj89kn2ke2tcuh19.cloudflarestream.com/b51e49994b6fd9e56b6f1fdfcd339fe6/manifest/video.m3u8'
+})
+
+// 播放配置
+const playConfig = reactive({
+  autoplay: true,
+  muted: true
+})
+
+// 当前播放状态
+const currentSrc = ref('')
+
+// 日志
+interface LogItem {
+  time: string
+  type: 'info' | 'success' | 'error'
+  message: string
+}
+const logs = ref<LogItem[]>([])
+
+function addLog(message: string, type: LogItem['type'] = 'info') {
+  const time = new Date().toLocaleTimeString()
+  logs.value.unshift({ time, type, message })
+  if (logs.value.length > 100) {
+    logs.value.pop()
+  }
+}
+
+// 播放 HLS
+function playHls() {
+  if (!hlsConfig.url) {
+    ElMessage.warning('请输入 M3U8 地址')
+    return
+  }
+  currentSrc.value = hlsConfig.url
+  addLog(`播放 HLS: ${hlsConfig.url}`, 'success')
+}
+
+// 播放控制
+function handlePlay() {
+  playerRef.value?.play()
+  addLog('播放', 'info')
+}
+
+function handlePause() {
+  playerRef.value?.pause()
+  addLog('暂停', 'info')
+}
+
+function handleStop() {
+  playerRef.value?.stop()
+  currentSrc.value = ''
+  addLog('停止', 'info')
+}
+
+function handleScreenshot() {
+  playerRef.value?.screenshot()
+  addLog('截图', 'info')
+}
+
+function handleFullscreen() {
+  playerRef.value?.fullscreen()
+  addLog('全屏', 'info')
+}
+
+// 事件处理
+function onPlay() {
+  addLog('视频开始播放', 'success')
+}
+
+function onPause() {
+  addLog('视频已暂停', 'info')
+}
+
+function onError(error: any) {
+  addLog(`播放错误: ${error?.message || JSON.stringify(error)}`, 'error')
+}
+</script>
+
+<style lang="scss" scoped>
+.page-container {
+  min-height: 100vh;
+  background-color: var(--bg-page);
+}
+
+.page-header {
+  display: flex;
+  align-items: center;
+  margin-bottom: 20px;
+  padding: 15px 20px;
+  background-color: var(--bg-container);
+  border-radius: var(--radius-base);
+  color: var(--text-primary);
+
+  .title {
+    font-size: 18px;
+    font-weight: 600;
+  }
+}
+
+.config-section {
+  margin-bottom: 20px;
+  padding: 20px;
+  background-color: var(--bg-container);
+  border-radius: var(--radius-base);
+}
+
+.player-section {
+  height: 500px;
+  margin-bottom: 20px;
+  border-radius: var(--radius-base);
+  overflow: hidden;
+  background-color: #000;
+
+  .player-placeholder {
+    height: 100%;
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    color: var(--text-secondary);
+
+    p {
+      margin-top: 15px;
+      font-size: 14px;
+    }
+  }
+}
+
+.control-section {
+  padding: 15px 20px;
+  background-color: var(--bg-container);
+  border-radius: var(--radius-base);
+  margin-bottom: 20px;
+}
+
+.log-section {
+  padding: 15px 20px;
+  background-color: var(--bg-container);
+  border-radius: var(--radius-base);
+
+  .log-header {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    margin-bottom: 10px;
+
+    h4 {
+      font-size: 14px;
+      margin: 0;
+      color: var(--text-primary);
+    }
+  }
+
+  .log-content {
+    max-height: 200px;
+    overflow-y: auto;
+    background-color: var(--bg-hover);
+    border-radius: var(--radius-sm);
+    padding: 10px;
+  }
+
+  .log-item {
+    font-size: 12px;
+    padding: 4px 0;
+    border-bottom: 1px solid var(--border-color-light);
+    color: var(--text-regular);
+
+    &:last-child {
+      border-bottom: none;
+    }
+
+    .time {
+      color: var(--text-secondary);
+      margin-right: 10px;
+    }
+
+    &.success .message {
+      color: var(--color-success);
+    }
+
+    &.error .message {
+      color: var(--color-danger);
+    }
+  }
+
+  .log-empty {
+    text-align: center;
+    color: var(--text-secondary);
+    font-size: 12px;
+    padding: 20px 0;
+  }
+}
+</style>

+ 18 - 53
src/views/stream/config.vue

@@ -1,6 +1,6 @@
 <template>
   <div class="page-container">
-    <el-card header="Cloudflare Stream 配置">
+    <el-card :header="t('Cloudflare Stream 配置')">
       <el-form :model="config" label-width="160px" style="max-width: 600px">
         <el-form-item label="Account ID" required>
           <el-input v-model="config.accountId" placeholder="Cloudflare Account ID" />
@@ -12,7 +12,7 @@
             <template #prepend>customer-</template>
             <template #append>.cloudflarestream.com</template>
           </el-input>
-          <div class="form-tip">播放域名的子域名部分</div>
+          <div class="form-tip">{{ t('播放域名的子域名部分') }}</div>
         </el-form-item>
 
         <el-form-item label="API Token">
@@ -23,20 +23,20 @@
             show-password
           />
           <div class="form-tip">
-            仅在前端直接调用 API 时需要(不推荐)
+            {{ t('仅在前端直接调用 API 时需要(不推荐)') }}
             <br />
-            推荐通过后端代理调用,避免暴露 Token
+            {{ t('推荐通过后端代理调用,避免暴露 Token') }}
           </div>
         </el-form-item>
 
         <el-form-item>
-          <el-button type="primary" @click="saveConfig">保存配置</el-button>
-          <el-button @click="testConnection">测试连接</el-button>
+          <el-button type="primary" @click="saveConfig">{{ t('保存配置') }}</el-button>
+          <el-button @click="testConnection">{{ t('测试连接') }}</el-button>
         </el-form-item>
       </el-form>
     </el-card>
 
-    <el-card header="配置说明" style="margin-top: 20px">
+    <el-card :header="t('配置说明')" style="margin-top: 20px">
       <el-collapse>
         <el-collapse-item title="如何获取 Account ID" name="1">
           <ol>
@@ -49,7 +49,7 @@
           </ol>
         </el-collapse-item>
 
-        <el-collapse-item title="如何获取 Customer Subdomain" name="2">
+        <el-collapse-item :title="t('如何获取 Customer Subdomain')" name="2">
           <ol>
             <li>进入 Stream 产品页面</li>
             <li>上传或选择任意视频</li>
@@ -80,54 +80,16 @@
           </el-alert>
         </el-collapse-item>
 
-        <el-collapse-item title="后端 API 代理示例" name="4">
-          <pre class="code-block">
-// Node.js / Express 示例
-const express = require('express');
-const axios = require('axios');
-
-const CLOUDFLARE_ACCOUNT_ID = process.env.CF_ACCOUNT_ID;
-const CLOUDFLARE_API_TOKEN = process.env.CF_API_TOKEN;
-
-const cfApi = axios.create({
-  baseURL: `https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/stream`,
-  headers: {
-    'Authorization': `Bearer ${CLOUDFLARE_API_TOKEN}`,
-    'Content-Type': 'application/json'
-  }
-});
-
-// 获取视频列表
-app.get('/api/stream/video/list', async (req, res) =&gt; {
-  const response = await cfApi.get('/');
-  res.json({
-    code: 200,
-    data: {
-      rows: response.data.result,
-      total: response.data.result.length
-    }
-  });
-});
-
-// 创建直播输入
-app.post('/api/stream/live', async (req, res) =&gt; {
-  const response = await cfApi.post('/live_inputs', req.body);
-  res.json({
-    code: 200,
-    data: response.data.result
-  });
-});</pre
-          >
-        </el-collapse-item>
+        <el-collapse-item title="后端 API 代理示例" name="4"></el-collapse-item>
       </el-collapse>
     </el-card>
 
-    <el-card header="快速测试" style="margin-top: 20px">
+    <el-card :header="t('快速测试')" style="margin-top: 20px">
       <el-form label-width="100px" style="max-width: 800px">
         <el-form-item label="Video ID">
           <el-input v-model="testVideoId" placeholder="输入 Video ID 测试播放" style="width: 400px" />
           <el-button type="primary" style="margin-left: 10px" @click="testPlay" :disabled="!testVideoId">
-            测试播放
+            {{ t('测试播放') }}
           </el-button>
         </el-form-item>
 
@@ -136,17 +98,17 @@ app.post('/api/stream/live', async (req, res) =&gt; {
             <div class="url-item">
               <span class="label">HLS:</span>
               <code>{{ testHlsUrl }}</code>
-              <el-button link type="primary" @click="copyUrl(testHlsUrl)">复制</el-button>
+              <el-button link type="primary" @click="copyUrl(testHlsUrl)">{{ t('复制') }}</el-button>
             </div>
             <div class="url-item">
               <span class="label">DASH:</span>
               <code>{{ testDashUrl }}</code>
-              <el-button link type="primary" @click="copyUrl(testDashUrl)">复制</el-button>
+              <el-button link type="primary" @click="copyUrl(testDashUrl)">{{ t('复制') }}</el-button>
             </div>
             <div class="url-item">
               <span class="label">iframe:</span>
               <code>{{ testIframeUrl }}</code>
-              <el-button link type="primary" @click="copyUrl(testIframeUrl)">复制</el-button>
+              <el-button link type="primary" @click="copyUrl(testIframeUrl)">{{ t('复制') }}</el-button>
             </div>
           </div>
         </el-form-item>
@@ -154,7 +116,7 @@ app.post('/api/stream/live', async (req, res) =&gt; {
     </el-card>
 
     <!-- 测试播放弹窗 -->
-    <el-dialog v-model="playDialogVisible" title="测试播放" width="900px" destroy-on-close>
+    <el-dialog v-model="playDialogVisible" :title="t('测试播放')" width="900px" destroy-on-close>
       <div class="player-container">
         <VideoPlayer
           v-if="playDialogVisible && testVideoId"
@@ -173,6 +135,9 @@ import { ref, reactive, computed, onMounted } from 'vue'
 import { ElMessage } from 'element-plus'
 import VideoPlayer from '@/components/VideoPlayer.vue'
 import { useStreamStore } from '@/store/stream'
+import { useI18n } from 'vue-i18n'
+
+const { t } = useI18n()
 
 const streamStore = useStreamStore()