Browse Source

feat: play media-center live streams inside cards

Replace window.open play-url with in-card LivePlayer,
keeping viewer lease heartbeat while watching.
main
xiaosi 2 weeks ago
parent
commit
bf3237806e
  1. 41
      src/components/MediaCard.vue
  2. 133
      src/views/MediaView/MediaView.vue

41
src/components/MediaCard.vue

@ -1,12 +1,23 @@
<template>
<article>
<div class="media-visual" :class="visualClass">
<div class="media-visual" :class="[visualClass, { 'is-playing': isLivePlaying }]">
<LivePlayer
v-if="isLivePlaying"
:play-url="playUrl"
:phase="phase"
:active="true"
:camera-label="name"
@toggle="$emit('toggle-live')"
@error="$emit('play-error', $event)"
/>
<template v-else>
<span v-if="variant === 'live'" class="live-corner"><i></i>LIVE</span>
<t-button shape="square" variant="text" @click="$emit('play')">
<svg><use href="#i-play" /></svg>
</t-button>
<small v-if="variant === 'live'">{{ source }}</small>
<small v-else class="media-duration">{{ duration }}</small>
</template>
</div>
<div class="media-card-copy">
<span><strong>{{ name }}</strong><small>{{ desc }}</small></span>
@ -25,13 +36,35 @@
</template>
<script setup>
defineProps({
import { computed } from 'vue'
import LivePlayer from '@/components/LivePlayer.vue'
const props = defineProps({
variant: { type: String, required: true }, // 'live' | 'video'
name: { type: String, required: true },
desc: { type: String, required: true },
visualClass: { type: String, required: true },
source: { type: String, default: '' },
duration: { type: String, default: '' }
duration: { type: String, default: '' },
playUrl: { type: String, default: '' },
phase: { type: String, default: '' },
playing: { type: Boolean, default: false },
})
defineEmits(['play', 'action'])
defineEmits(['play', 'action', 'toggle-live', 'play-error'])
const isLivePlaying = computed(() => props.variant === 'live' && props.playing)
</script>
<style scoped>
.media-visual.is-playing {
background-image: none;
background: #1e2d34;
}
.media-visual.is-playing :deep(.video-frame) {
width: 100%;
height: 100%;
aspect-ratio: auto;
border-radius: 0;
}
</style>

133
src/views/MediaView/MediaView.vue

@ -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>

Loading…
Cancel
Save