立即注册
@@ -63,6 +66,7 @@ import { getCodeImg } from "@/api/login";
import Cookies from "js-cookie";
import { getToken } from "@/utils/auth";
import { homePath } from "@/utils/homePath";
+import { hasJoinbrightTokenInLocation } from "@/utils/embeddedAuth";
import { encrypt, decrypt } from '@/utils/jsencrypt'
import md5 from 'md5.js'
@@ -88,6 +92,8 @@ export default {
// code: [{ required: true, trigger: "change", message: "请输入验证码" }]
},
loading: false,
+ // 地址上还带着中恒凭证时,守卫会先换票再离开登录页,这段时间不画登录表单
+ joining: hasJoinbrightTokenInLocation(),
// 验证码开关
captchaEnabled: true,
// 注册开关
@@ -107,8 +113,8 @@ export default {
this.getCode();
this.getCookie();
// 免登录进入:首次打开系统时用默认普通账号(htjc)自动登录;
- // 显式退出后(登录页带有 manual-login 标记)不再自动登录,由用户手动输入账号密码切换
- if (!getToken() && sessionStorage.getItem('manual-login') !== '1') {
+ // 显式退出,或中恒凭证登录失败后,停在登录页,由用户手动输入账号密码
+ if (!this.joining && !getToken() && sessionStorage.getItem('manual-login') !== '1' && sessionStorage.getItem('joinbright-login-error') !== '1') {
this.loginForm.username = 'htjc';
this.loginForm.password = 'htjc@123';
this.login();
@@ -154,7 +160,9 @@ export default {
this.$store.dispatch("Login", postData).then(() => {
// 登录成功后清除手动登录标记,下次进入系统恢复免登录
sessionStorage.removeItem('manual-login');
- this.$router.push({ path: this.redirect || homePath }).catch(() => { });
+ sessionStorage.removeItem('joinbright-login-error');
+ const redirect = this.redirect || (this.$route.query && this.$route.query.redirect);
+ this.$router.push({ path: redirect || homePath }).catch(() => { });
}).catch(() => {
this.loading = false;
@@ -172,6 +180,11 @@ export default {
this.login()
}
});
+ },
+ handleForget() {
+ this.$alert("请联系管理员重置密码", "忘记密码", {
+ confirmButtonText: "确定"
+ });
}
}
};
@@ -179,39 +192,119 @@ export default {
\ No newline at end of file
diff --git a/src/views/video/LivePreview.vue b/src/views/video/LivePreview.vue
index 83cc3c7..64094e9 100644
--- a/src/views/video/LivePreview.vue
+++ b/src/views/video/LivePreview.vue
@@ -11,10 +11,11 @@
:config="config"
:channels="channels"
:selected-code="selectedCode"
- :loading="loading"
+ :loading="!embed && loading"
:use-agg-api="isAggMode"
:station-code="embedStationCode"
:default-expanded="embedTreeExpanded"
+ @station-change="handleEmbedStationChange"
@channels-loaded="handleTreeChannels"
@select="selectChannel"
@open="openChannel"
@@ -70,11 +71,14 @@
- {{ slot.name }}
+ {{ slot.name }}
@@ -252,7 +256,7 @@ import treeToggleIcon from '@/assets/portal/tree-toggle.png'
import treeCollapseIcon from '@/assets/portal/tree-collapse.png'
import {
getNvrConfig, startLive, getLiveStatus, stopLive, controlPtz,
- getPresetList, createPreset, createSnapshot, downloadSnapshot, controlWiper
+ getPresetList, createPreset, createSnapshot, downloadSnapshot
} from '@/api/nvrVideo'
import { startRealtimeVideo, stopRealtimeVideo, aggPtzControl, aggPresetList, aggPresetCreate } from '@/api/portal'
@@ -273,7 +277,7 @@ const PTZ_CODES = {
// 地址参数:只看某一个场站,空字符串为全部场站
embedStationCode: { type: String, default: '' },
// 地址参数:导航树默认展开
- embedTreeExpanded: { type: Boolean, default: false },
+ embedTreeExpanded: { type: Boolean, default: true },
// 地址参数:云台控制面板默认展开
embedControlExpanded: { type: Boolean, default: false },
// 地址参数:默认宫格,1 / 4 / 9 / 16
@@ -286,7 +290,7 @@ const PTZ_CODES = {
treeChannelsLoaded: false,
treeChannelCodes: new Set(),
controlPanelExpanded: false,
- embedTreeVisible: false,
+ embedTreeVisible: true,
embedControlVisible: false,
embedPanelDrag: {
tree: { x: 0, y: 0, moved: false },
@@ -437,11 +441,14 @@ const PTZ_CODES = {
deactivated() {
this.pauseStreams()
},
- beforeDestroy() {
+ beforeDestroy() {
this.stopTreeResize()
this.stopEmbedPanelDrag()
this.lifecycleId += 1
this.pageActive = false
+ if (this.ptzActive) this.stopPtz()
+ clearInterval(this.ptzRepeatTimer)
+ this.ptzRepeatTimer = null
document.removeEventListener('fullscreenchange', this.handleFullscreenChange)
document.removeEventListener('webkitfullscreenchange', this.handleFullscreenChange)
if (!this.embed) {
@@ -583,6 +590,15 @@ const PTZ_CODES = {
if (!name) return true
return /^(camera|cam|channel)[-_ ]?\d+$/i.test(String(name).trim())
},
+ handleEmbedStationChange(stationCode) {
+ if (!this.embed) return
+ const next = stationCode || ''
+ if (String((this.$route.query && this.$route.query.stationCode) || '') === next) return
+ const query = { ...this.$route.query }
+ if (next) query.stationCode = next
+ else delete query.stationCode
+ this.$router.replace({ path: this.$route.path, query }).catch(() => {})
+ },
handleTreeChannels(channels) {
this.treeChannelsLoaded = true
const treeCodes = new Set((channels || []).filter(item => item && item.cameraCode).map(item => item.cameraCode))
@@ -654,9 +670,15 @@ const PTZ_CODES = {
this.playbackHistory = []
this.activeIndex = 0
this.controlTarget = null
- for (let index = 0; index < channels.length; index += 1) {
+ // 实时监控和嵌入预览都一次发 3 路,避免第 6 路占满浏览器连接后后面一直 pending。
+ const batchSize = 3
+ for (let index = 0; index < channels.length; index += batchSize) {
if (!this.pageActive) return
- await this.startChannel(channels[index], index)
+ const batch = []
+ for (let offset = 0; offset < batchSize && index + offset < channels.length; offset += 1) {
+ batch.push(this.startChannel(channels[index + offset], index + offset))
+ }
+ await Promise.all(batch)
}
if (highlightCode) {
const highlightIndex = channels.findIndex(item => item && item.cameraCode === highlightCode)
@@ -712,8 +734,10 @@ const PTZ_CODES = {
startPromise: null,
muted: true,
errorNotified: false,
+ streamFailed: false,
attemptedCameraCodes: [...attemptedCameraCodes, channel.cameraCode],
timer: null,
+ pollGeneration: 0,
streamType
}
this.$set(this.slots, index, slot)
@@ -727,15 +751,17 @@ const PTZ_CODES = {
try {
// 汇聚模式:拉流走首页同款聚合接口(响应含 hls/flv,applyLiveResult 直接兼容)
slot.startPromise = this.isAggMode
- ? startRealtimeVideo({ stationCode: channel.stationCode, cameraCode: channel.cameraCode })
+ ? startRealtimeVideo(
+ { stationCode: channel.stationCode, cameraCode: channel.cameraCode },
+ { lane: "embed" }
+ )
: startLive(channel, streamType)
const result = await slot.startPromise
if (!this.pageActive || this.slots[index] !== slot) return
this.applyLiveResult(index, slot, result)
if (!slot.streamSource.hlsUrl && !slot.streamSource.flvUrl) {
if (this.isAggMode) {
- slot.loading = false
- this.$message.error('视频流启动失败,请稍后重试')
+ this.handleStreamError(new Error('未获取到视频流'), index)
} else {
this.pollLive(index, slot, 0)
}
@@ -746,8 +772,7 @@ const PTZ_CODES = {
// 切页取消的拉流不是播放失败,不能再弹错误,也不能继续占着画面。
if (!this.pageActive || (error && error.code === 'ERR_CANCELED')) return
if (this.slots[index] === slot) {
- this.$set(this.slots, index, null)
- this.$message.error(error.message)
+ this.handleStreamError(error, index)
}
}
},
@@ -758,23 +783,36 @@ const PTZ_CODES = {
slot.streamSource.hlsUrl = payload.hlsUrl || payload.hls || (/\.m3u8(?:\?|$)/i.test(mediaUrl) ? mediaUrl : '')
slot.streamSource.flvUrl = payload.flvUrl || payload.flv || (/\.flv(?:\?|$)/i.test(mediaUrl) ? mediaUrl : '')
slot.loading = !(slot.streamSource.hlsUrl || slot.streamSource.flvUrl)
+ if (!slot.loading) slot.streamFailed = false
+ },
+ markStreamFailed(slot) {
+ if (!slot) return
+ slot.loading = false
+ slot.streamFailed = true
+ slot.streamSource = { hlsUrl: '', flvUrl: '' }
},
pollLive(index, slot, attempt) {
if (!this.pageActive || this.slots[index] !== slot) return
if (attempt >= 20) {
- slot.loading = false
- this.$message.error('视频流启动超时,请检查摄像机或流媒体服务')
+ this.handleStreamError(new Error('视频流播放超时'), index)
return
}
+ const generation = slot.pollGeneration
+ clearTimeout(slot.timer)
slot.timer = setTimeout(async() => {
- if (!this.pageActive || this.slots[index] !== slot) return
+ if (!this.pageActive || this.slots[index] !== slot || slot.pollGeneration !== generation) return
try {
const result = await getLiveStatus(slot.cameraCode)
+ if (!this.pageActive || this.slots[index] !== slot || slot.pollGeneration !== generation) return
this.applyLiveResult(index, slot, result)
if (!result || result.status !== 'PLAYING' || !(slot.streamSource.hlsUrl || slot.streamSource.flvUrl)) {
this.pollLive(index, slot, attempt + 1)
}
- } catch (_) { this.pollLive(index, slot, attempt + 1) }
+ } catch (_) {
+ if (this.pageActive && this.slots[index] === slot && slot.pollGeneration === generation) {
+ this.pollLive(index, slot, attempt + 1)
+ }
+ }
}, 800)
},
async closeSlot(index, notify = true) {
@@ -782,7 +820,9 @@ const PTZ_CODES = {
if (!slot) return
const cameraCode = slot.cameraCode
if (index === this.activeIndex && this.ptzActive) this.stopPtz()
+ slot.pollGeneration += 1
clearTimeout(slot.timer)
+ slot.timer = null
this.$set(this.slots, index, null)
this.forgetPlayback(cameraCode)
if (this.controlTarget === slot) this.controlTarget = null
@@ -906,12 +946,16 @@ const PTZ_CODES = {
let nextChannel = null
for (let offset = 1; offset <= this.channels.length; offset += 1) {
const candidate = this.channels[(currentIndex + offset + this.channels.length) % this.channels.length]
- if (candidate && !attemptedCodes.has(candidate.cameraCode) && !occupiedCodes.has(candidate.cameraCode)) {
+ if (
+ candidate &&
+ this.isChannelOnline(candidate) &&
+ !attemptedCodes.has(candidate.cameraCode) &&
+ !occupiedCodes.has(candidate.cameraCode)
+ ) {
nextChannel = candidate
break
}
}
- this.$set(this.slots, index, null)
try {
if (this.isAggMode && failedSlot.stationCode) {
await stopRealtimeVideo({ stationCode: failedSlot.stationCode, cameraCode: failedSlot.cameraCode })
@@ -919,11 +963,12 @@ const PTZ_CODES = {
await stopLive(failedSlot.cameraCode)
}
} catch (_) { /* continue switching cameras */ }
- if (!this.pageActive) return
+ if (!this.pageActive || this.slots[index] !== failedSlot) return
if (nextChannel) {
+ this.$set(this.slots, index, null)
await this.startChannel(nextChannel, index, Array.from(attemptedCodes))
} else {
- this.$message.error((error && error.message) || '视频播放失败,暂无其他可用摄像头')
+ this.markStreamFailed(failedSlot)
}
},
fullscreenElement() {
@@ -983,19 +1028,32 @@ const PTZ_CODES = {
? this.slots.find(slot => slot && slot.cameraCode === cameraCode) || this.controlTarget
: this.controlTarget
const targetCode = (target && target.cameraCode) || cameraCode
- if (!targetCode) { this.$message.warning('请先选择有视频的画面'); return }
+ if (!targetCode) {
+ const error = new Error('请先选择有视频的画面')
+ error.resultUncertain = false
+ throw error
+ }
let result
if (this.isAggMode) {
// 汇聚模式:stationCode 取当前控制摄像头所属的站(而非右上角站名筛选)
const stationCode = target && target.stationCode
- if (!stationCode) { this.$message.warning('未获取到该摄像头所属站点信息'); return }
+ if (!stationCode) {
+ const error = new Error('未获取到该摄像头所属站点信息')
+ error.resultUncertain = false
+ throw error
+ }
result = await aggPtzControl({ stationCode, cameraCode: targetCode, controlCode, controlPara1, controlPara2 })
} else {
result = await controlPtz({ cameraCode: targetCode, controlCode, controlPara1, controlPara2 })
}
// 兼容两种响应:聚合代理可能包一层 data;resultCode 非 0 视为失败
const body = result && Object.prototype.hasOwnProperty.call(result, 'data') && result.data ? result.data : result
- if (body && body.resultCode !== undefined && Number(body.resultCode) !== 0) throw new Error('云台控制失败')
+ if (body && body.resultCode !== undefined && Number(body.resultCode) !== 0) {
+ const error = new Error('云台控制失败')
+ error.resultUncertain = false
+ throw error
+ }
+ return body
},
startPtz(action) {
if (action === 'STOP') {
@@ -1120,29 +1178,12 @@ const PTZ_CODES = {
if (!this.controlTarget) { this.$message.warning('请先选择有视频的画面'); return }
this.$set(this.controlTarget, 'muted', !(this.controlTarget.muted !== false))
},
- getIvsIdentity() {
- const firstValue = value => Array.isArray(value) ? value[0] : value
- const query = this.$route.query || {}
- const username = firstValue(query.username) || sessionStorage.getItem('username') || this.$store.getters.name
- const joinbrightToken = firstValue(query['joinbright-token']) || firstValue(query.token) || sessionStorage.getItem('joinbright-token')
- return { username: username || undefined, joinbrightToken: joinbrightToken || undefined }
- },
async sendWiperCommand(cameraCode, controlCode) {
try {
- const result = await controlWiper({
- cameraCode,
- controlCode,
- controlPara1: '1',
- controlPara2: '',
- ...this.getIvsIdentity()
- })
- if (!result || typeof result !== 'object') {
- const error = new Error('雨刷器接口响应无效')
- error.resultUncertain = true
- throw error
- }
- if (Number(result.resultCode) !== 0) {
- const error = new Error(result.message || '雨刷器控制失败')
+ // 雨刷与云台同一套控制接口:汇聚页走 /agg/proxy/patrol/preset/ptzControl,单站走 /patrol/preset/ptzControl
+ const result = await this.sendPtz(controlCode, '1', '', cameraCode)
+ if (result === undefined) {
+ const error = new Error('雨刷器控制失败')
error.resultUncertain = false
throw error
}
@@ -1221,7 +1262,7 @@ const PTZ_CODES = {
.monitor-head { height: 32px; display: flex; align-items: center; justify-content: space-between; color: #cfe5ff; }.monitor-head small { color: #648fbc; }
.camera-grid { flex: 1; min-height: 0; display: grid; gap: 3px; background: rgba(14, 75, 146, .5); border: 1px solid #176bc2; }
.camera-card { position: relative; min-width: 0; min-height: 0; overflow: hidden; border: 1px solid transparent; background: #050b24; cursor: pointer; }.camera-card.active { border-color: #54ffff; box-shadow: inset 0 0 0 1px rgba(84,255,255,.35); }
-.camera-label { position: absolute; left: 0; right: 0; top: 0; height: 28px; padding: 0 8px; display: flex; align-items: center; justify-content: space-between; color: #d8eaff; background: linear-gradient(180deg, rgba(0,10,36,.92), transparent); font-size: 11px; pointer-events: none; user-select: none; }.camera-label i { display: inline-block; width: 6px; height: 6px; margin-right: 5px; border-radius: 50%; background: #24e39f; box-shadow: 0 0 6px #24e39f; }
+.camera-label { position: absolute; left: 0; right: 0; top: 0; height: 28px; padding: 0 8px; display: flex; align-items: center; justify-content: space-between; color: #d8eaff; background: linear-gradient(180deg, rgba(0,10,36,.92), transparent); font-size: 11px; pointer-events: none; user-select: none; }.camera-label i { display: inline-block; width: 6px; height: 6px; margin-right: 5px; border-radius: 50%; background: #24e39f; box-shadow: 0 0 6px #24e39f; }.camera-label i.failed { background: #ff6252; box-shadow: 0 0 6px #ff6252; }
.camera-actions { position: absolute; right: 5px; bottom: 5px; display: none; gap: 3px; }.camera-card:hover .camera-actions, .camera-card:fullscreen .camera-actions, .camera-card:-webkit-full-screen .camera-actions { display: flex; }.camera-actions button { width: 26px; height: 25px; border: 1px solid rgba(66, 167, 255, .55); color: #bfe0ff; background: rgba(4,28,78,.88); cursor: pointer; }
.empty-slot { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; color: #466f9c; background: radial-gradient(circle at center, #0b2a61, #050b24 70%); user-select: none; }.empty-slot i { font-size: 28px; }.empty-slot span { font-size: 11px; }
.control-panel { position: absolute; top: 0; right: 0; bottom: 0; z-index: 4; width: 242px; min-width: 242px; height: auto; margin: 0; padding: 0; overflow: visible; border: 1px solid rgba(63,127,216,.72); border-radius: 2px; background: rgba(7, 24, 78, .42); color: #dcecff; font-size: 14px; line-height: normal; box-sizing: border-box; cursor: move; user-select: none; }
diff --git a/src/views/video/LivePreviewEmbed.vue b/src/views/video/LivePreviewEmbed.vue
index c6f93ec..e10586c 100644
--- a/src/views/video/LivePreviewEmbed.vue
+++ b/src/views/video/LivePreviewEmbed.vue
@@ -41,7 +41,7 @@ function readQuery(route) {
const control = String(query.control == null ? '' : query.control).toLowerCase()
return {
stationCode: query.stationCode ? String(query.stationCode) : '',
- treeExpanded: tree === '1' || tree === 'open' || tree === 'true',
+ treeExpanded: !(tree === '0' || tree === 'close' || tree === 'false' || tree === 'collapsed'),
controlExpanded: control === '1' || control === 'open' || control === 'true',
layout: LAYOUTS.includes(layout) ? layout : 9
}
@@ -61,6 +61,11 @@ export default {
layout: query.layout
}
},
+ watch: {
+ '$route.query.stationCode'(value) {
+ this.stationCode = value ? String(value) : ''
+ }
+ },
created() {
this.ensureSession()
},
diff --git a/src/views/video/components/NvrDeviceTree.vue b/src/views/video/components/NvrDeviceTree.vue
index 9ddf621..2bf0352 100644
--- a/src/views/video/components/NvrDeviceTree.vue
+++ b/src/views/video/components/NvrDeviceTree.vue
@@ -4,21 +4,43 @@
设备列表
{{ visibleChannelCount }}
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
import { getAreaEqubookTreeSelect } from '@/api/videosMonitor/index'
-import { getChannelTree } from '@/api/portal'
+import { getChannelTree, getStationList } from '@/api/portal'
function flatten(nodes, result = []) {
const source = nodes || []
@@ -100,7 +122,12 @@ export default {
expandedKeys: [],
keywords: '',
onlineStatus: '',
+ selectedStationCode: this.stationCode || '',
+ stationList: [],
+ stationSelectKey: 0,
+ syncingStation: false,
internalLoading: false,
+ treeRequestId: 0,
treeProps: { children: 'children', label: 'label' }
}
},
@@ -112,21 +139,55 @@ export default {
},
watch: {
onlineStatus: 'refreshTree',
+ stationCode(value) {
+ const next = value || ''
+ if (next === this.selectedStationCode) return
+ this.syncingStation = true
+ this.selectedStationCode = next
+ this.$nextTick(() => { this.syncingStation = false })
+ },
+ selectedStationCode(value) {
+ if (!this.useAggApi || this.syncingStation) return
+ this.$emit('station-change', value || '')
+ this.loadTree()
+ },
selectedCode(value) {
const node = this.channelNodes.find(item => this.toCameraCode(item) === value)
if (node && this.$refs.treeRef) this.$refs.treeRef.setCurrentKey(node.id)
}
},
- mounted() { this.loadTree() },
+ mounted() {
+ this.loadStations()
+ this.loadTree()
+ },
methods: {
+ async loadStations() {
+ if (!this.useAggApi) return
+ try {
+ const response = await getStationList()
+ const rows = Array.isArray(response && response.data)
+ ? response.data
+ : (response && response.data && Array.isArray(response.data.rows) ? response.data.rows : [])
+ this.stationList = rows.map(item => ({
+ code: String(item.stationCode || item.code || ''),
+ name: item.stationName || item.name || item.stationCode || item.code || ''
+ })).filter(item => item.code)
+ // 选项晚于选中值到达时,下拉会停在编码上,重建一次才显示场站名称
+ if (this.selectedStationCode) this.stationSelectKey += 1
+ } catch (error) {
+ this.stationList = []
+ }
+ },
async loadTree() {
+ const requestId = ++this.treeRequestId
this.internalLoading = true
try {
const queryParams = { patroldeviceName: '', onlineStatus: '', patroldeviceTypeFlag: 'ipc' }
- if (this.useAggApi && this.stationCode) queryParams.stationCode = this.stationCode
+ if (this.useAggApi && this.selectedStationCode) queryParams.stationCode = this.selectedStationCode
const response = this.useAggApi
? await getChannelTree(queryParams)
: await getAreaEqubookTreeSelect(queryParams)
+ if (requestId !== this.treeRequestId) return
const nodes = Array.isArray(response.data) ? response.data : []
this.treeNodes = nodes
this.expandedKeys = []
@@ -137,9 +198,12 @@ export default {
this.refreshTree()
this.$emit('channels-loaded', this.channelNodes.map(this.normalizeChannel))
} catch (error) {
+ if (requestId !== this.treeRequestId) return
this.treeNodes = []
this.$message.error(error.message || '设备树加载失败')
- } finally { this.internalLoading = false }
+ } finally {
+ if (requestId === this.treeRequestId) this.internalLoading = false
+ }
},
refreshTree() { this.treeData = filterTree(this.treeNodes, this.keywords, this.onlineStatus) },
toCameraCode(node) { return node.cameraCode || node.channelCode || '' },
@@ -170,8 +234,12 @@ export default {
.tree-title { height: 46px; padding: 0 14px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid rgba(41,139,255,.35); color: #54ffff; font-size: 14px; }
.tree-title i { margin-right: 6px; }
.tree-title strong { min-width: 28px; text-align: center; color: #b7d8ff; }
-.tree-filters { display: flex; gap: 6px; padding: 10px 8px; }
+.tree-filters { display: flex; flex-direction: column; gap: 6px; padding: 10px 8px; }
+.tree-filter-row { display: flex; align-items: center; gap: 6px; min-width: 0; }
.tree-filters .el-select { width: 78px; }
+.tree-filters .station-select { flex: 1 1 auto; width: auto; min-width: 0; }
+.tree-filters .status-select { flex: 0 0 62px; width: 62px; }
+.tree-filters .status-select ::v-deep .el-input__inner { padding-left: 6px; padding-right: 18px; }
.tree-filters .el-input { flex: 1; min-width: 0; }
.tree-filters .el-button { flex: 0 0 32px; padding: 0; border-color: #2787ed; color: #54ffff; background: #0a2b67; }
.tree-filters .el-button:hover, .tree-filters .el-button:focus { border-color: #2787ed; color: #ffffff; background: #176bc2; }
diff --git a/src/views/video/components/NvrStreamPlayer.vue b/src/views/video/components/NvrStreamPlayer.vue
index 1eb2ffb..eafa214 100644
--- a/src/views/video/components/NvrStreamPlayer.vue
+++ b/src/views/video/components/NvrStreamPlayer.vue
@@ -1,9 +1,9 @@
-
+
- {{ loading ? '正在连接视频流' : placeholder }}
+ {{ loading ? '正在连接视频流' : (failed ? '' : placeholder) }}
@@ -19,10 +19,15 @@ export default {
muted: { type: Boolean, default: true },
autoplay: { type: Boolean, default: true },
loading: { type: Boolean, default: false },
- placeholder: { type: String, default: '等待打开视频通道' }
+ failed: { type: Boolean, default: false },
+ placeholder: { type: String, default: '等待打开视频通道' },
+ // 同一页面超过浏览器每域名 6 条 HTTP/1.1 连接时,不能再占一条不结束的直播连接。
+ releaseConnection: { type: Boolean, default: false },
+ // cover 裁掉多余部分铺满格子,contain 按比例留黑边
+ fit: { type: String, default: 'contain' }
},
data() {
- return { hls: null, flv: null, ready: false, hlsRecoveries: 0, fallbackUsed: false, loadTimer: null }
+ return { hls: null, flv: null, ready: false, hlsRecoveries: 0, fallbackUsed: false, loadTimer: null, attachToken: 0, watchdog: null, released: false }
},
watch: {
source: {
@@ -37,6 +42,7 @@ export default {
this.attach()
},
beforeDestroy() {
+ this.released = true
this.destroyPlayer()
},
methods: {
@@ -47,137 +53,210 @@ export default {
pause() {
if (this.$refs.video) this.$refs.video.pause()
},
+ clearTimers() {
+ clearTimeout(this.loadTimer)
+ clearInterval(this.watchdog)
+ this.loadTimer = null
+ this.watchdog = null
+ },
+ // HTTP-FLV 会一直占一条连接。Chrome/Edge 对同一域名的 HTTP/1.1 上限是 6,
+ // 九宫格后三路就会一直排队。ZLMediaKit 的同路径 WebSocket-FLV 不占这个名额。
+ // 站端返回的是相对路径,先按当前页面补成完整地址,再换成 ws/wss。
+ websocketFlvUrl(url) {
+ if (!url) return ''
+ let absolute = String(url)
+ if (!/^(https?|wss?):\/\//i.test(absolute)) {
+ try {
+ absolute = new URL(absolute, window.location.href).href
+ } catch (_) {
+ return ''
+ }
+ }
+ if (/^wss?:\/\//i.test(absolute)) return absolute
+ if (!/^https?:\/\//i.test(absolute)) return ''
+ return absolute.replace(/^http/i, 'ws')
+ },
+ releaseVideo(video) {
+ if (!video) return
+ video.onloadeddata = null
+ video.oncanplay = null
+ video.onplaying = null
+ video.onerror = null
+ try { video.pause() } catch (_) { /* element already gone */ }
+ video.removeAttribute('src')
+ try { video.load() } catch (_) { /* element already gone */ }
+ },
destroyPlayer() {
+ this.clearTimers()
if (this.hls) {
- this.hls.destroy()
+ const hls = this.hls
this.hls = null
+ try { hls.stopLoad() } catch (_) { /* already stopped */ }
+ try { hls.detachMedia() } catch (_) { /* already detached */ }
+ try { hls.destroy() } catch (_) { /* player already released */ }
}
if (this.flv) {
- try { this.flv.destroy() } catch (_) { /* player already released */ }
+ const flv = this.flv
this.flv = null
+ try { flv.pause() } catch (_) { /* already paused */ }
+ try { flv.unload() } catch (_) { /* already unloaded */ }
+ try { flv.detachMediaElement() } catch (_) { /* already detached */ }
+ try { flv.destroy() } catch (_) { /* player already released */ }
}
- const video = this.$refs.video
- if (video) {
- video.pause()
- video.removeAttribute('src')
- video.load()
- }
+ this.releaseVideo(this.$refs.video)
this.ready = false
this.hlsRecoveries = 0
this.fallbackUsed = false
- clearTimeout(this.loadTimer)
- this.loadTimer = null
+ },
+ isCurrent(token) {
+ return !this.released && token === this.attachToken
+ },
+ armWatchdog(token, onTimeout) {
+ this.clearTimers()
+ this.watchdog = setInterval(() => {
+ if (!this.isCurrent(token) || this.ready) {
+ this.clearTimers()
+ return
+ }
+ this.clearTimers()
+ onTimeout()
+ }, 10000)
},
attach() {
+ this.attachToken += 1
+ const token = this.attachToken
this.$nextTick(() => {
+ if (!this.isCurrent(token)) return
const video = this.$refs.video
if (!video) return
const flvUrl = this.source && this.source.flvUrl
const hlsUrl = this.source && this.source.hlsUrl
this.destroyPlayer()
+ if (!this.isCurrent(token)) return
+ const currentToken = token
video.muted = this.muted
const markReady = () => {
- clearTimeout(this.loadTimer)
- this.loadTimer = null
+ if (!this.isCurrent(currentToken)) return
+ this.clearTimers()
this.ready = true
this.$emit('ready')
}
video.onloadeddata = markReady
video.oncanplay = markReady
video.onplaying = markReady
- if (hlsUrl) {
- this.loadTimer = setTimeout(() => {
- if (!this.ready) this.fallbackToFlv(video, flvUrl)
- }, 10000)
- this.attachHls(video, hlsUrl, flvUrl)
+ // 拉流地址已经有了但画面一直出不来时,不能停在占位图上。
+ this.armWatchdog(currentToken, () => {
+ if (!this.isCurrent(currentToken) || this.ready) return
+ this.destroyPlayer()
+ this.$emit('error', new Error('视频流播放超时'))
+ })
+ if (this.releaseConnection && flvUrl) {
+ this.attachFlv(video, flvUrl, currentToken)
+ }
+ else if (hlsUrl) {
+ this.attachHls(video, hlsUrl, flvUrl, currentToken)
+ }
+ else if (flvUrl) this.attachFlv(video, flvUrl, currentToken)
+ else video.onerror = () => {
+ if (this.isCurrent(currentToken)) this.$emit('error', new Error('视频播放失败'))
}
- else if (flvUrl) this.attachFlv(video, flvUrl)
- else video.onerror = () => this.$emit('error', new Error('视频播放失败'))
})
},
- attachFlv(video, url) {
+ attachFlv(video, url, token = this.attachToken) {
+ if (!this.isCurrent(token)) return
if (!url || !flvjs.isSupported()) {
this.$emit('error', new Error('当前浏览器不支持 HTTP-FLV 播放'))
return
}
video.onerror = null
- this.flv = flvjs.createPlayer(
- { type: 'flv', url, isLive: true },
+ const socketUrl = this.releaseConnection ? this.websocketFlvUrl(url) : ''
+ const flv = flvjs.createPlayer(
+ { type: 'flv', url: socketUrl || url, isLive: true },
{ enableStashBuffer: false, lazyLoad: false, autoCleanupSourceBuffer: true }
)
- this.flv.attachMediaElement(video)
- this.flv.load()
- clearTimeout(this.loadTimer)
- this.loadTimer = setTimeout(() => {
- if (!this.ready && this.flv) {
- try { this.flv.destroy() } catch (_) { /* ignore */ }
- this.flv = null
- this.$emit('error', new Error('HTTP-FLV stream timeout'))
- }
- }, 10000)
- if (this.autoplay) Promise.resolve(this.flv.play()).catch(() => undefined)
- this.flv.on(flvjs.Events.ERROR, () => this.$emit('error', new Error('HTTP-FLV 播放失败')))
+ this.flv = flv
+ flv.attachMediaElement(video)
+ flv.load()
+ if (this.autoplay) Promise.resolve(flv.play()).catch(() => undefined)
+ flv.on(flvjs.Events.ERROR, () => {
+ if (this.isCurrent(token) && this.flv === flv) this.$emit('error', new Error('HTTP-FLV 播放失败'))
+ })
},
- fallbackToFlv(video, fallbackFlvUrl) {
- clearTimeout(this.loadTimer)
- this.loadTimer = null
+ fallbackToFlv(video, fallbackFlvUrl, token = this.attachToken) {
+ if (!this.isCurrent(token)) return
if (!fallbackFlvUrl || this.fallbackUsed) {
+ this.clearTimers()
this.$emit('error', new Error('HLS 播放失败'))
return
}
this.fallbackUsed = true
if (this.hls) {
- this.hls.destroy()
+ const hls = this.hls
this.hls = null
+ try { hls.stopLoad() } catch (_) { /* already stopped */ }
+ try { hls.detachMedia() } catch (_) { /* already detached */ }
+ try { hls.destroy() } catch (_) { /* already destroyed */ }
}
- this.attachFlv(video, fallbackFlvUrl)
+ this.attachFlv(video, fallbackFlvUrl, token)
},
- attachHls(video, url, fallbackFlvUrl = '') {
+ attachHls(video, url, fallbackFlvUrl = '', token = this.attachToken) {
+ if (!this.isCurrent(token)) return
if (this.flv) {
- try { this.flv.destroy() } catch (_) { /* ignore */ }
+ const flv = this.flv
this.flv = null
+ try { flv.pause() } catch (_) { /* ignore */ }
+ try { flv.unload() } catch (_) { /* ignore */ }
+ try { flv.detachMediaElement() } catch (_) { /* ignore */ }
+ try { flv.destroy() } catch (_) { /* ignore */ }
}
if (Hls.isSupported()) {
+ // 低延迟会发 blocking playlist reload,progressive 会改成长连接拉流。
+ // 这两条都会一直占着浏览器到该域名的名额。Chrome/Edge 对同一域名的
+ // HTTP/1.1 并发上限是 6,九宫格第 7 路起就会一直 pending。
+ // 超过 6 路时关掉这两项,拉完一片就断开,连接可以还给后面的画面。
const hls = new Hls({
enableWorker: true,
- lowLatencyMode: true,
+ lowLatencyMode: !this.releaseConnection,
+ progressive: false,
liveDurationInfinity: true,
backBufferLength: 2,
maxBufferLength: 3,
maxMaxBufferLength: 5,
- manifestLoadingMaxRetry: 3,
- levelLoadingMaxRetry: 3,
- fragLoadingMaxRetry: 3
+ manifestLoadingMaxRetry: 0,
+ levelLoadingMaxRetry: 0,
+ fragLoadingMaxRetry: 0
})
this.hls = hls
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
- if (this.hls === hls) hls.loadSource(url)
+ if (this.isCurrent(token) && this.hls === hls) hls.loadSource(url)
})
hls.on(Hls.Events.MANIFEST_PARSED, () => {
- if (this.autoplay) video.play().catch(() => undefined)
+ if (this.isCurrent(token) && this.hls === hls && this.autoplay) video.play().catch(() => undefined)
})
hls.on(Hls.Events.ERROR, (_, detail) => {
- if (this.hls !== hls || !detail || !detail.fatal) return
+ if (!this.isCurrent(token) || this.hls !== hls || !detail || !detail.fatal) return
if (detail.type === Hls.ErrorTypes.MEDIA_ERROR && this.hlsRecoveries < 1) {
this.hlsRecoveries += 1
hls.recoverMediaError()
} else {
- hls.destroy()
- this.hls = null
- this.fallbackToFlv(video, fallbackFlvUrl)
+ try { hls.stopLoad() } catch (_) { /* already stopped */ }
+ try { hls.destroy() } catch (_) { /* already destroyed */ }
+ if (this.hls === hls) this.hls = null
+ this.fallbackToFlv(video, fallbackFlvUrl, token)
}
})
hls.attachMedia(video)
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.onerror = () => {
+ if (!this.isCurrent(token)) return
video.removeAttribute('src')
- if (fallbackFlvUrl) this.attachFlv(video, fallbackFlvUrl)
+ if (fallbackFlvUrl) this.attachFlv(video, fallbackFlvUrl, token)
else this.$emit('error', new Error('HLS 播放失败'))
}
video.src = url
if (this.autoplay) video.play().catch(() => undefined)
} else {
- if (fallbackFlvUrl) this.attachFlv(video, fallbackFlvUrl)
+ if (fallbackFlvUrl) this.attachFlv(video, fallbackFlvUrl, token)
else this.$emit('error', new Error('当前浏览器不支持 HLS 播放'))
}
}
@@ -188,6 +267,7 @@ export default {