|
|
|
@ -31,7 +31,12 @@ |
|
|
|
:desc="s.desc" |
|
|
|
:source="s.source" |
|
|
|
:visual-class="s.visualClass" |
|
|
|
:playing="activeLive?.id === s.id" |
|
|
|
:play-url="activeLive?.id === s.id ? activeLive.playUrl : ''" |
|
|
|
:phase="activeLive?.id === s.id ? activeLive.phase : s.phase" |
|
|
|
@play="openLive(s)" |
|
|
|
@toggle-live="closeActiveLive()" |
|
|
|
@play-error="onActivePlayError" |
|
|
|
@action="ui.toast('更多操作')" |
|
|
|
/> |
|
|
|
<div v-if="!liveStreams.length" class="table-empty" style="grid-column:1/-1">暂无直播流</div> |
|
|
|
@ -66,11 +71,12 @@ |
|
|
|
</template> |
|
|
|
|
|
|
|
<script setup> |
|
|
|
import { computed, onMounted, ref, watch } from 'vue' |
|
|
|
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue' |
|
|
|
import { useRoute, useRouter } from 'vue-router' |
|
|
|
import { useUiStore } from '@/stores/modules/uiStore' |
|
|
|
import request from '@/utils/http' |
|
|
|
import * as urls from '@/config/urls' |
|
|
|
import { getLivePlayURL, heartbeatLive, joinLive, leaveLive } from '@/api/live' |
|
|
|
import MediaCard from '@/components/MediaCard.vue' |
|
|
|
|
|
|
|
const route = useRoute() |
|
|
|
@ -90,6 +96,16 @@ function setView(v) { |
|
|
|
|
|
|
|
const liveStreams = ref([]) |
|
|
|
const videos = ref([]) |
|
|
|
const activeLive = reactive({ |
|
|
|
id: '', |
|
|
|
dockId: '', |
|
|
|
sessionId: '', |
|
|
|
playUrl: '', |
|
|
|
phase: '', |
|
|
|
leaseExpiresAt: 0, |
|
|
|
}) |
|
|
|
let heartbeatTimer = null |
|
|
|
let openSeq = 0 |
|
|
|
|
|
|
|
const liveTabLabel = computed(() => `直播中心 ${liveStreams.value.length}`) |
|
|
|
const videosTabLabel = computed(() => `原始视频 ${videos.value.length}`) |
|
|
|
@ -108,54 +124,135 @@ function fmtSize(bytes) { |
|
|
|
return mb >= 1024 ? `${(mb / 1024).toFixed(1)} GB` : `${mb.toFixed(1)} MB` |
|
|
|
} |
|
|
|
|
|
|
|
function clearHeartbeat() { |
|
|
|
window.clearTimeout(heartbeatTimer) |
|
|
|
heartbeatTimer = null |
|
|
|
} |
|
|
|
|
|
|
|
function resetActiveLive() { |
|
|
|
clearHeartbeat() |
|
|
|
activeLive.id = '' |
|
|
|
activeLive.dockId = '' |
|
|
|
activeLive.sessionId = '' |
|
|
|
activeLive.playUrl = '' |
|
|
|
activeLive.phase = '' |
|
|
|
activeLive.leaseExpiresAt = 0 |
|
|
|
} |
|
|
|
|
|
|
|
function scheduleHeartbeat(expiresAt) { |
|
|
|
clearHeartbeat() |
|
|
|
if (!activeLive.dockId || !activeLive.sessionId) return |
|
|
|
const delay = Math.max(5000, (Number(expiresAt) * 1000 - Date.now()) / 2) |
|
|
|
heartbeatTimer = window.setTimeout(async () => { |
|
|
|
if (!activeLive.dockId || !activeLive.sessionId) return |
|
|
|
const dockId = activeLive.dockId |
|
|
|
const sessionId = activeLive.sessionId |
|
|
|
try { |
|
|
|
const result = await heartbeatLive(dockId, sessionId) |
|
|
|
if (activeLive.sessionId !== sessionId || activeLive.dockId !== dockId) return |
|
|
|
if (result?.session?.phase) activeLive.phase = result.session.phase |
|
|
|
scheduleHeartbeat(result.leaseExpiresAt) |
|
|
|
} catch (e) { |
|
|
|
if (activeLive.sessionId !== sessionId || activeLive.dockId !== dockId) return |
|
|
|
ui.toast(e.message || '直播观看已结束') |
|
|
|
resetActiveLive() |
|
|
|
} |
|
|
|
}, delay) |
|
|
|
} |
|
|
|
|
|
|
|
async function closeActiveLive({ sendLeave = true } = {}) { |
|
|
|
const dockId = activeLive.dockId |
|
|
|
const sessionId = activeLive.sessionId |
|
|
|
resetActiveLive() |
|
|
|
if (sendLeave && dockId && sessionId) { |
|
|
|
try { |
|
|
|
await leaveLive(dockId, sessionId) |
|
|
|
} catch (_) {} |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
async function load() { |
|
|
|
try { |
|
|
|
const [livePage, videoPage] = await Promise.all([ |
|
|
|
request.get(urls.LIVE, { params: { pageNum: 1, pageSize: 100 } }), |
|
|
|
request.get(urls.VIDEOS, { params: { pageNum: 1, pageSize: 100 } }) |
|
|
|
request.get(urls.VIDEOS, { params: { pageNum: 1, pageSize: 100 } }), |
|
|
|
]) |
|
|
|
liveStreams.value = (livePage?.records || []).filter((s) => ['starting', 'streaming', 'reconnecting'].includes(s.phase)).map((s) => ({ |
|
|
|
liveStreams.value = (livePage?.records || []) |
|
|
|
.filter((s) => ['starting', 'streaming', 'reconnecting'].includes(s.phase)) |
|
|
|
.map((s) => ({ |
|
|
|
id: s.id, |
|
|
|
dockId: s.dockId, |
|
|
|
phase: s.phase, |
|
|
|
name: s.dockId, |
|
|
|
desc: s.streamName ? `${s.streamName} · ${s.provider}` : s.provider, |
|
|
|
source: s.streamName || '--', |
|
|
|
visualClass: 'dock-camera' |
|
|
|
visualClass: 'dock-camera', |
|
|
|
})) |
|
|
|
videos.value = (videoPage?.records || []).map((v, i) => ({ |
|
|
|
id: String(v.id), |
|
|
|
name: v.fileName || '未命名视频', |
|
|
|
desc: `${v.droneSn || '未知设备'} · ${fmtSize(v.fileSize)}`, |
|
|
|
duration: fmtDuration(v.duration), |
|
|
|
visualClass: i % 2 === 0 ? 'aerial-1' : 'aerial-2' |
|
|
|
visualClass: i % 2 === 0 ? 'aerial-1' : 'aerial-2', |
|
|
|
})) |
|
|
|
|
|
|
|
if (activeLive.id && !liveStreams.value.some((s) => s.id === activeLive.id)) { |
|
|
|
await closeActiveLive() |
|
|
|
} |
|
|
|
} catch (e) { |
|
|
|
ui.toast(e.message || '加载媒体数据失败') |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
onMounted(load) |
|
|
|
async function openLive(stream) { |
|
|
|
if (!stream?.dockId) return |
|
|
|
if (activeLive.id === stream.id && activeLive.playUrl) { |
|
|
|
await closeActiveLive() |
|
|
|
return |
|
|
|
} |
|
|
|
|
|
|
|
const seq = ++openSeq |
|
|
|
await closeActiveLive() |
|
|
|
|
|
|
|
try { |
|
|
|
const result = await request.post(urls.LIVE_SESSIONS(stream.dockId)) |
|
|
|
if (result?.session?.phase === 'streaming') { |
|
|
|
if (!result.session?.id) { |
|
|
|
const result = await joinLive(stream.dockId) |
|
|
|
if (seq !== openSeq) return |
|
|
|
const session = result?.session |
|
|
|
if (!session?.id) { |
|
|
|
ui.toast('直播会话无效') |
|
|
|
return |
|
|
|
} |
|
|
|
const play = await request.get(urls.LIVE_PLAY_URL(stream.dockId), { |
|
|
|
params: { streamSessionId: result.session.id } |
|
|
|
}) |
|
|
|
if (play?.playUrl && !play.playUrl.startsWith('fake://')) window.open(play.playUrl, '_blank') |
|
|
|
else ui.toast('控制面已验证,当前未配置可播放媒体流') |
|
|
|
} else { |
|
|
|
|
|
|
|
activeLive.id = stream.id |
|
|
|
activeLive.dockId = stream.dockId |
|
|
|
activeLive.sessionId = session.id |
|
|
|
activeLive.phase = session.phase || '' |
|
|
|
activeLive.playUrl = '' |
|
|
|
scheduleHeartbeat(result.leaseExpiresAt) |
|
|
|
|
|
|
|
if (session.phase !== 'streaming') { |
|
|
|
ui.toast('直播正在启动,请稍后重试') |
|
|
|
return |
|
|
|
} |
|
|
|
|
|
|
|
const play = await getLivePlayURL(stream.dockId, session.id) |
|
|
|
if (seq !== openSeq || activeLive.sessionId !== session.id) return |
|
|
|
if (play?.playUrl && !play.playUrl.startsWith('fake://')) { |
|
|
|
activeLive.playUrl = play.playUrl |
|
|
|
} else { |
|
|
|
ui.toast('控制面已验证,当前未配置可播放媒体流') |
|
|
|
} |
|
|
|
} catch (e) { |
|
|
|
if (seq !== openSeq) return |
|
|
|
await closeActiveLive() |
|
|
|
ui.toast(e.message || '打开直播失败') |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
function onActivePlayError(err) { |
|
|
|
if (err?.name === 'AbortError' || err?.name === 'NotAllowedError') return |
|
|
|
ui.toast(err?.message || '播放失败') |
|
|
|
} |
|
|
|
|
|
|
|
async function download(v) { |
|
|
|
try { |
|
|
|
@ -170,6 +267,12 @@ async function download(v) { |
|
|
|
ui.toast(e.message || '下载失败') |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
onMounted(load) |
|
|
|
onBeforeUnmount(() => { |
|
|
|
openSeq += 1 |
|
|
|
closeActiveLive() |
|
|
|
}) |
|
|
|
</script> |
|
|
|
|
|
|
|
<style scoped> |
|
|
|
|