|
|
<template>
|
|
|
<div class="playback-console">
|
|
|
<div class="playback-body">
|
|
|
<nvr-device-tree
|
|
|
:config="config"
|
|
|
:channels="channels"
|
|
|
:selected-code="selectedCode"
|
|
|
:loading="loading"
|
|
|
open-hint="单击通道选择历史回放设备"
|
|
|
@select="selectChannel"
|
|
|
@open="selectChannel"
|
|
|
/>
|
|
|
|
|
|
<main class="playback-workspace">
|
|
|
<div class="query-bar">
|
|
|
<label><span>日期</span><input v-model="queryDate" type="date"></label>
|
|
|
<label><span>开始时间</span><input v-model="startTime" type="time" step="1"></label>
|
|
|
<em>至</em>
|
|
|
<label><span>结束时间</span><input v-model="endTime" type="time" step="1"></label>
|
|
|
<label><span>下载时刻</span><input v-model="clipTime" type="time" step="1" title="留空时使用当前回放时刻"></label>
|
|
|
<div class="query-actions">
|
|
|
<button type="button" class="primary" :disabled="busy" @click="queryPlayback"><i class="el-icon-search" />查询录像</button>
|
|
|
<button type="button" :disabled="clipDownloading" @click="downloadClip"><i class="el-icon-download" />{{ clipDownloading ? '正在生成' : '下载1分钟' }}</button>
|
|
|
<button type="button" @click="reset"><i class="el-icon-refresh-left" />重置</button>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
<section ref="videoStage" class="video-stage">
|
|
|
<video ref="video" playsinline muted />
|
|
|
<div class="stage-head">
|
|
|
<span><i :class="{ playing: sessionStatus === 'PLAYING' }" />{{ statusLabel }}</span>
|
|
|
<small>{{ selectedChannelName }}</small>
|
|
|
<em>HLS / BEIJING</em>
|
|
|
</div>
|
|
|
<div v-if="overlayVisible" class="stage-overlay">
|
|
|
<i :class="busy ? 'el-icon-loading' : 'el-icon-video-camera'" />
|
|
|
<strong>{{ overlayTitle }}</strong>
|
|
|
<span>{{ overlayText }}</span>
|
|
|
</div>
|
|
|
<div class="stage-clock">{{ currentClock }}</div>
|
|
|
</section>
|
|
|
|
|
|
<section class="timeline-panel">
|
|
|
<div class="timeline-head">
|
|
|
<span><i />录像轨道</span>
|
|
|
<small>{{ timelineRangeText }}</small>
|
|
|
</div>
|
|
|
<div class="timeline" @click="seekByTimeline">
|
|
|
<div class="timeline-track">
|
|
|
<div class="actual-range" :style="actualRangeStyle" />
|
|
|
<div class="timeline-marker" :style="markerStyle"><span>{{ currentClock }}</span></div>
|
|
|
</div>
|
|
|
<div v-if="!hasActualRange" class="timeline-empty">查询后显示实际录像区间</div>
|
|
|
<div class="timeline-labels"><span v-for="label in timelineLabels" :key="label">{{ label }}</span></div>
|
|
|
</div>
|
|
|
</section>
|
|
|
|
|
|
<section class="playback-controls">
|
|
|
<div class="transport">
|
|
|
<button type="button" title="跳到录像起点" @click="seekBoundary('start')"><i class="el-icon-d-arrow-left" /></button>
|
|
|
<button type="button" class="play" title="播放或暂停" @click="togglePlay"><i :class="sessionStatus === 'PLAYING' ? 'el-icon-video-pause' : 'el-icon-video-play'" /></button>
|
|
|
<button type="button" title="跳到录像末尾" @click="seekBoundary('end')"><i class="el-icon-d-arrow-right" /></button>
|
|
|
<div class="time-readout"><strong>{{ currentClock }}</strong><span>{{ timelineRangeText }}</span></div>
|
|
|
</div>
|
|
|
<div class="control-center">
|
|
|
<div class="volume"><i :class="volume ? 'el-icon-microphone' : 'el-icon-turn-off-microphone'" /><input v-model.number="volume" type="range" min="0" max="1" step="0.05" @input="syncVolume"></div>
|
|
|
<div class="speed-group">
|
|
|
<button v-for="speed in speeds" :key="speed" type="button" :class="{ active: playbackSpeed === speed }" @click="setSpeed(speed)">{{ speed }}x</button>
|
|
|
</div>
|
|
|
<div class="jump-control"><span>跳转</span><input v-model="jumpTime" type="time" step="1"><button type="button" @click="jumpToTime">定位</button></div>
|
|
|
</div>
|
|
|
<button type="button" class="fullscreen" title="全屏" @click="fullscreen"><i class="el-icon-full-screen" /></button>
|
|
|
</section>
|
|
|
|
|
|
<div class="status-line" :class="messageType"><i /><span>{{ statusMessage }}</span></div>
|
|
|
</main>
|
|
|
</div>
|
|
|
</div>
|
|
|
</template>
|
|
|
|
|
|
<script>
|
|
|
import Hls from 'hls.js'
|
|
|
import NvrDeviceTree from './components/NvrDeviceTree.vue'
|
|
|
import {
|
|
|
getNvrConfig, createPlaybackSession, getPlaybackSession,
|
|
|
controlPlayback, deletePlaybackSession, downloadPlaybackClip
|
|
|
} from '@/api/nvrVideo'
|
|
|
|
|
|
const pad = value => String(value).padStart(2, '0')
|
|
|
const compactToDate = value => {
|
|
|
if (!value || !/^\d{14}$/.test(value)) return null
|
|
|
return new Date(Date.UTC(+value.slice(0, 4), +value.slice(4, 6) - 1, +value.slice(6, 8), +value.slice(8, 10), +value.slice(10, 12), +value.slice(12, 14)))
|
|
|
}
|
|
|
const dateToCompact = date => `${date.getUTCFullYear()}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}`
|
|
|
const compactToInput = value => {
|
|
|
const date = compactToDate(value)
|
|
|
return date ? `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())} ${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())}` : ''
|
|
|
}
|
|
|
const compactToClock = value => {
|
|
|
const date = compactToDate(value)
|
|
|
return date ? `${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())}` : '--:--:--'
|
|
|
}
|
|
|
|
|
|
export default {
|
|
|
name: 'VideoHistoryPlayback',
|
|
|
components: { NvrDeviceTree },
|
|
|
data() {
|
|
|
const now = new Date(Date.now() + 8 * 60 * 60 * 1000)
|
|
|
return {
|
|
|
config: {}, channels: [], selectedCode: '', loading: false,
|
|
|
connectionText: '读取配置', connectionState: '',
|
|
|
queryDate: now.toISOString().slice(0, 10), startTime: '00:00:00', endTime: '23:59:59',
|
|
|
clipTime: '', jumpTime: '', volume: 0.7, speeds: [1, 2, 4, 8], playbackSpeed: 1,
|
|
|
session: null, pollTimer: null, hls: null, hlsRetryTimer: null, loadedUrl: '', busy: false, clipDownloading: false,
|
|
|
queryStart: null, queryEnd: null, mediaReady: false,
|
|
|
overlayTitle: '选择时间范围开始回放', overlayText: '页面会自动创建 HLS 会话并等待首个可播放切片', overlayVisible: true,
|
|
|
statusMessage: '正在读取服务端设备配置', messageType: 'info'
|
|
|
}
|
|
|
},
|
|
|
computed: {
|
|
|
selectedChannel() { return this.channels.find(item => item.cameraCode === this.selectedCode) || null },
|
|
|
selectedChannelName() { return this.selectedChannel ? (this.selectedChannel.name || `通道 ${this.selectedChannel.channel}`) : '未选择通道' },
|
|
|
sessionStatus() { return (this.session && this.session.status) || 'IDLE' },
|
|
|
statusLabel() {
|
|
|
return { IDLE: '待查询', STARTING: '等待首帧', PLAYING: '回放中', PAUSED: '已暂停', STOPPED: '已停止', FAILED: '播放失败' }[this.sessionStatus] || this.sessionStatus
|
|
|
},
|
|
|
currentClock() { return compactToClock(this.session && this.session.currentTime) },
|
|
|
actualRange() { return this.session && Array.isArray(this.session.actualTimeRange) ? this.session.actualTimeRange : null },
|
|
|
hasActualRange() { return Boolean(this.actualRange && this.actualRange.length === 2) },
|
|
|
timelineRangeText() { return this.hasActualRange ? `${compactToClock(this.actualRange[0])} — ${compactToClock(this.actualRange[1])}` : '等待查询范围' },
|
|
|
rangeMetrics() {
|
|
|
if (!this.queryStart || !this.queryEnd || !this.hasActualRange) return { left: 0, width: 0, marker: 0 }
|
|
|
const start = compactToDate(this.queryStart); const end = compactToDate(this.queryEnd)
|
|
|
const actualStart = compactToDate(this.actualRange[0]); const actualEnd = compactToDate(this.actualRange[1])
|
|
|
if (!start || !end || !actualStart || !actualEnd || end <= start) return { left: 0, width: 0, marker: 0 }
|
|
|
const total = end - start
|
|
|
const left = Math.max(0, Math.min(100, ((actualStart - start) / total) * 100))
|
|
|
const width = Math.max(1, Math.min(100 - left, ((actualEnd - actualStart) / total) * 100))
|
|
|
const current = compactToDate(this.session.currentTime) || actualStart
|
|
|
return { left, width, marker: Math.max(0, Math.min(100, ((current - start) / total) * 100)) }
|
|
|
},
|
|
|
actualRangeStyle() { return { left: `${this.rangeMetrics.left}%`, width: `${this.rangeMetrics.width}%` } },
|
|
|
markerStyle() { return { left: `${this.rangeMetrics.marker}%` } },
|
|
|
timelineLabels() {
|
|
|
if (!this.queryStart || !this.queryEnd) return ['00:00', '06:00', '12:00', '18:00', '24:00']
|
|
|
const start = compactToDate(this.queryStart); const end = compactToDate(this.queryEnd)
|
|
|
if (!start || !end) return []
|
|
|
return [0, 0.25, 0.5, 0.75, 1].map(value => {
|
|
|
const date = new Date(start.getTime() + (end - start) * value)
|
|
|
return `${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}`
|
|
|
})
|
|
|
}
|
|
|
},
|
|
|
created() { this.loadConfig() },
|
|
|
mounted() {
|
|
|
const video = this.$refs.video
|
|
|
video.volume = this.volume
|
|
|
video.addEventListener('playing', () => { this.mediaReady = true; this.overlayVisible = false })
|
|
|
video.addEventListener('ratechange', () => { if (video.playbackRate !== 1) video.playbackRate = 1 })
|
|
|
},
|
|
|
beforeDestroy() { this.stopSession() },
|
|
|
methods: {
|
|
|
setStatus(message, type = 'info') { this.statusMessage = message; this.messageType = type },
|
|
|
async loadConfig() {
|
|
|
this.loading = true
|
|
|
try {
|
|
|
const config = await getNvrConfig()
|
|
|
this.config = config || {}
|
|
|
this.channels = Array.isArray(config.channels) ? config.channels : []
|
|
|
const routeCode = this.$route.query.cameraCode
|
|
|
const selected = this.channels.find(item => item.cameraCode === routeCode) || this.channels.find(item => item.cameraCode === config.defaultCameraCode) || this.channels[0]
|
|
|
if (selected) this.selectChannel(selected, false)
|
|
|
this.connectionText = '设备服务在线'; this.connectionState = 'online'
|
|
|
this.setStatus('选择通道和时间范围后查询录像')
|
|
|
} catch (error) {
|
|
|
this.connectionText = '服务不可达'; this.connectionState = 'error'
|
|
|
this.overlayTitle = '无法读取设备配置'; this.overlayText = error.message
|
|
|
this.setStatus(error.message, 'error')
|
|
|
} finally { this.loading = false }
|
|
|
},
|
|
|
async selectChannel(channel, stop = true) {
|
|
|
if (!channel || channel.cameraCode === this.selectedCode) return
|
|
|
if (stop) await this.stopSession()
|
|
|
if (!this.channels.some(item => item.cameraCode === channel.cameraCode)) this.channels.push(channel)
|
|
|
this.selectedCode = channel.cameraCode
|
|
|
this.$router.replace({ query: { ...this.$route.query, cameraCode: channel.cameraCode }}).catch(() => undefined)
|
|
|
this.overlayVisible = true; this.overlayTitle = '选择时间范围开始回放'; this.overlayText = '已切换通道,请查询录像'
|
|
|
},
|
|
|
values() {
|
|
|
if (!this.queryDate || !this.startTime || !this.endTime) throw new Error('请选择完整的日期、开始时间和结束时间')
|
|
|
const start = `${this.queryDate} ${this.startTime}`
|
|
|
const end = `${this.queryDate} ${this.endTime}`
|
|
|
const startCompact = start.replace(/[- :]/g, ''); const endCompact = end.replace(/[- :]/g, '')
|
|
|
if (startCompact.length !== 14 || endCompact.length !== 14 || endCompact <= startCompact) throw new Error('结束时间必须晚于开始时间')
|
|
|
return { start, end, startCompact, endCompact }
|
|
|
},
|
|
|
async queryPlayback() {
|
|
|
if (this.busy) return
|
|
|
if (!this.selectedCode) { this.setStatus('请先选择摄像机', 'warn'); return }
|
|
|
let values
|
|
|
try { values = this.values() } catch (error) { this.setStatus(error.message, 'error'); return }
|
|
|
await this.stopSession()
|
|
|
this.busy = true; this.queryStart = values.startCompact; this.queryEnd = values.endCompact
|
|
|
this.overlayVisible = true; this.overlayTitle = '正在检索录像'; this.overlayText = 'NVR 正在返回实际连续录像区间'
|
|
|
this.setStatus(`正在查询 ${values.start} 至 ${values.end}`)
|
|
|
try {
|
|
|
this.session = await createPlaybackSession({ cameraCode: this.selectedCode, streamType: 1, startTime: values.start, endTime: values.end })
|
|
|
this.applySession(this.session)
|
|
|
this.schedulePoll(0)
|
|
|
} catch (error) {
|
|
|
this.overlayTitle = error.status === 404 ? '这个时间段没有录像' : '回放创建失败'; this.overlayText = error.message
|
|
|
this.setStatus(error.message, error.status === 404 ? 'warn' : 'error')
|
|
|
} finally { this.busy = false }
|
|
|
},
|
|
|
schedulePoll(delay = 1000) {
|
|
|
clearTimeout(this.pollTimer)
|
|
|
if (!this.session || ['STOPPED', 'FAILED'].includes(this.session.status)) return
|
|
|
this.pollTimer = setTimeout(this.pollSession, delay)
|
|
|
},
|
|
|
async pollSession() {
|
|
|
if (!this.session) return
|
|
|
try {
|
|
|
this.session = await getPlaybackSession(this.session.sessionId)
|
|
|
this.applySession(this.session)
|
|
|
this.schedulePoll(this.session.status === 'STARTING' ? 650 : 1200)
|
|
|
} catch (error) {
|
|
|
this.setStatus(error.message, 'error'); this.overlayVisible = true; this.overlayTitle = '回放流启动失败'; this.overlayText = error.message
|
|
|
this.destroyHls(); this.session = null
|
|
|
}
|
|
|
},
|
|
|
applySession(session) {
|
|
|
this.playbackSpeed = Number(session.speed || 1)
|
|
|
if (session.hlsUrl && ['STARTING', 'PLAYING'].includes(session.status)) this.attachHls(session.hlsUrl)
|
|
|
if (session.status === 'PLAYING') {
|
|
|
if (this.mediaReady) this.overlayVisible = false
|
|
|
this.setStatus(`正在播放 ${this.timelineRangeText} · ${this.playbackSpeed}x`, 'success')
|
|
|
this.$refs.video.play().catch(() => undefined)
|
|
|
} else if (session.status === 'PAUSED') {
|
|
|
this.$refs.video.pause(); this.overlayVisible = true; this.overlayTitle = '回放已暂停'; this.overlayText = '点击播放按钮继续'
|
|
|
} else if (session.status === 'STARTING') {
|
|
|
this.overlayVisible = true; this.overlayTitle = '正在等待回放流'; this.overlayText = 'HLS 正在等待首个可播放切片'
|
|
|
}
|
|
|
},
|
|
|
attachHls(url) {
|
|
|
if (this.loadedUrl === url && this.hls) return
|
|
|
this.destroyHls(); this.loadedUrl = url; this.mediaReady = false
|
|
|
const video = this.$refs.video
|
|
|
if (Hls.isSupported()) {
|
|
|
const hls = new Hls({
|
|
|
enableWorker: true,
|
|
|
lowLatencyMode: false,
|
|
|
backBufferLength: 30,
|
|
|
maxBufferLength: 16,
|
|
|
manifestLoadingMaxRetry: 8,
|
|
|
levelLoadingMaxRetry: 8,
|
|
|
fragLoadingMaxRetry: 8
|
|
|
})
|
|
|
this.hls = hls
|
|
|
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
|
|
|
if (this.hls === hls) hls.loadSource(`${url}${url.includes('?') ? '&' : '?'}uiGeneration=${this.session ? this.session.generation : Date.now()}`)
|
|
|
})
|
|
|
hls.on(Hls.Events.MANIFEST_PARSED, () => video.play().catch(() => undefined))
|
|
|
hls.on(Hls.Events.ERROR, (_, detail) => {
|
|
|
if (this.hls !== hls || !detail || !detail.fatal) return
|
|
|
this.setStatus('HLS 播放异常,正在自动恢复', 'warn')
|
|
|
if (detail.type === Hls.ErrorTypes.NETWORK_ERROR) {
|
|
|
clearTimeout(this.hlsRetryTimer)
|
|
|
this.hlsRetryTimer = setTimeout(() => {
|
|
|
if (this.hls === hls) hls.startLoad()
|
|
|
}, 800)
|
|
|
} else if (detail.type === Hls.ErrorTypes.MEDIA_ERROR) {
|
|
|
hls.recoverMediaError()
|
|
|
}
|
|
|
})
|
|
|
hls.attachMedia(video)
|
|
|
} else if (video.canPlayType('application/vnd.apple.mpegurl')) { video.src = url; video.play().catch(() => undefined) }
|
|
|
},
|
|
|
destroyHls() {
|
|
|
clearTimeout(this.hlsRetryTimer); this.hlsRetryTimer = null
|
|
|
if (this.hls) { this.hls.destroy(); this.hls = null }
|
|
|
this.loadedUrl = ''; this.mediaReady = false
|
|
|
const video = this.$refs.video
|
|
|
if (video) { video.pause(); video.removeAttribute('src'); video.load() }
|
|
|
},
|
|
|
async sendControl(action, payload = {}) {
|
|
|
if (!this.session || this.busy) return
|
|
|
this.busy = true
|
|
|
try {
|
|
|
this.session = await controlPlayback(this.session.sessionId, { cameraCode: this.selectedCode, action, ...payload })
|
|
|
this.applySession(this.session); this.schedulePoll(this.session.status === 'STARTING' ? 650 : 1200)
|
|
|
} catch (error) { this.setStatus(error.message, 'error') } finally { this.busy = false }
|
|
|
},
|
|
|
togglePlay() { if (!this.session) this.setStatus('请先查询录像', 'warn'); else this.sendControl(this.sessionStatus === 'PLAYING' ? 'PAUSE' : 'RESUME') },
|
|
|
setSpeed(speed) { this.sendControl('SET_SPEED', { speed }) },
|
|
|
seek(value) { if (value) this.sendControl('SEEK', { seekTime: compactToInput(value) }) },
|
|
|
seekBoundary(side) {
|
|
|
if (!this.hasActualRange) return
|
|
|
if (side === 'start') this.seek(this.actualRange[0])
|
|
|
else {
|
|
|
const end = compactToDate(this.actualRange[1]); end.setUTCSeconds(end.getUTCSeconds() - 1); this.seek(dateToCompact(end))
|
|
|
}
|
|
|
},
|
|
|
seekByTimeline(event) {
|
|
|
if (!this.session || !this.queryStart || !this.queryEnd) return
|
|
|
const rect = event.currentTarget.getBoundingClientRect(); const fraction = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width))
|
|
|
const start = compactToDate(this.queryStart); const end = compactToDate(this.queryEnd)
|
|
|
this.seek(dateToCompact(new Date(start.getTime() + (end - start) * fraction)))
|
|
|
},
|
|
|
jumpToTime() {
|
|
|
if (!this.jumpTime) { this.setStatus('请输入跳转时间', 'warn'); return }
|
|
|
const time = this.jumpTime.length === 5 ? `${this.jumpTime}:00` : this.jumpTime
|
|
|
this.seek(`${this.queryDate.replace(/-/g, '')}${time.replace(/:/g, '')}`)
|
|
|
},
|
|
|
syncVolume() { const video = this.$refs.video; video.volume = this.volume; video.muted = this.volume === 0 },
|
|
|
fullscreen() { const stage = this.$refs.videoStage; if (stage && stage.requestFullscreen) stage.requestFullscreen() },
|
|
|
async stopSession() {
|
|
|
clearTimeout(this.pollTimer)
|
|
|
const session = this.session
|
|
|
this.session = null
|
|
|
if (session && session.sessionId) {
|
|
|
try { await controlPlayback(session.sessionId, { cameraCode: session.cameraCode || this.selectedCode, action: 'STOP' }) } catch (_) { /* cleanup */ }
|
|
|
try { await deletePlaybackSession(session.sessionId) } catch (_) { /* cleanup */ }
|
|
|
}
|
|
|
this.destroyHls()
|
|
|
},
|
|
|
reset() {
|
|
|
this.stopSession(); this.startTime = '00:00:00'; this.endTime = '23:59:59'; this.clipTime = ''; this.jumpTime = ''
|
|
|
this.queryStart = null; this.queryEnd = null; this.overlayVisible = true; this.overlayTitle = '选择时间范围开始回放'; this.overlayText = '页面会自动创建 HLS 会话并等待首个可播放切片'; this.setStatus('选择通道和时间范围后查询录像')
|
|
|
},
|
|
|
async downloadClip() {
|
|
|
if (!this.selectedCode) { this.$message.warning('请先选择摄像机'); return }
|
|
|
const centerTime = this.clipTime ? `${this.queryDate} ${this.clipTime}` : compactToInput(this.session && this.session.currentTime)
|
|
|
if (!centerTime) { this.$message.warning('请输入下载时刻,或先开始回放'); return }
|
|
|
this.clipDownloading = true
|
|
|
try {
|
|
|
const blob = await downloadPlaybackClip({ cameraCode: this.selectedCode, streamType: 1, centerTime, beforeSeconds: 30, afterSeconds: 30 })
|
|
|
const filename = `playback-${centerTime.replace(/[- :]/g, '')}.mp4`
|
|
|
const url = URL.createObjectURL(blob); const link = document.createElement('a')
|
|
|
link.href = url; link.download = filename; link.click(); setTimeout(() => URL.revokeObjectURL(url), 2000)
|
|
|
this.$message.success('录像片段已下载')
|
|
|
} catch (error) { this.$message.error(error.message) } finally { this.clipDownloading = false }
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
</script>
|
|
|
|
|
|
<style lang="scss" scoped>
|
|
|
.playback-console { height: 100%; min-height: 0; display: flex; flex-direction: column; color: #dcecff; background: #0b1248; }
|
|
|
.playback-body { flex: 1; min-height: 0; display: flex; }.playback-workspace { flex: 1; min-width: 0; min-height: 0; padding: 10px 14px; display: flex; flex-direction: column; gap: 9px; }
|
|
|
.query-bar { min-height: 58px; display: flex; align-items: flex-end; gap: 9px; padding: 7px 10px; border: 1px solid rgba(42,133,230,.38); background: rgba(7,30,84,.68); }.query-bar label { display: flex; flex-direction: column; gap: 5px; color: #7fa8d1; font-size: 11px; }.query-bar input { width: 126px; height: 29px; padding: 0 8px; border: 1px solid rgba(55,151,247,.48); color: #d4eaff; background: #09265e; outline: none; }.query-bar label:first-child input { width: 140px; }.query-bar em { height: 29px; line-height: 29px; color: #6c97c2; font-style: normal; }.query-actions { margin-left: auto; display: flex; gap: 6px; }.query-actions button { height: 30px; padding: 0 11px; border: 1px solid rgba(50,147,244,.52); color: #a9d3ff; background: #0a2b67; cursor: pointer; }.query-actions button.primary { color: #fff; background: #176bc2; }.query-actions button i { margin-right: 5px; }.query-actions button:disabled { opacity: .55; cursor: not-allowed; }
|
|
|
.video-stage { position: relative; flex: 1; min-height: 260px; overflow: hidden; border: 1px solid #176bc2; background: #03081d; }.video-stage video { width: 100%; height: 100%; display: block; object-fit: contain; }.stage-head { position: absolute; z-index: 2; top: 0; left: 0; right: 0; height: 34px; padding: 0 10px; display: flex; align-items: center; gap: 12px; color: #c9e3ff; background: linear-gradient(180deg,rgba(2,9,31,.94),transparent); font-size: 11px; }.stage-head span { display: flex; align-items: center; gap: 6px; }.stage-head span i { width: 7px; height: 7px; border-radius: 50%; background: #d09231; }.stage-head span i.playing { background: #25dfa2; box-shadow: 0 0 8px #25dfa2; }.stage-head small { color: #7da8d4; }.stage-head em { margin-left: auto; color: #5e8dbb; font-style: normal; }.stage-overlay { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 9px; color: #6d9bc8; background: radial-gradient(circle at center,rgba(10,45,96,.65),rgba(3,8,29,.92)); }.stage-overlay i { font-size: 36px; }.stage-overlay strong { color: #b9d8f7; font-size: 14px; }.stage-overlay span { font-size: 11px; }.stage-clock { position: absolute; right: 10px; bottom: 8px; color: #a7caec; font-family: Consolas,monospace; font-size: 12px; }
|
|
|
.timeline-panel { padding: 8px 10px 4px; border: 1px solid rgba(43,133,226,.38); background: rgba(7,29,80,.72); }.timeline-head { display: flex; align-items: center; justify-content: space-between; color: #bcd9f7; font-size: 11px; }.timeline-head span { display: flex; align-items: center; gap: 6px; }.timeline-head i { width: 6px; height: 6px; border-radius: 50%; background: #1a8cff; }.timeline-head small { color: #6d98c2; }.timeline { position: relative; height: 46px; margin-top: 4px; cursor: pointer; }.timeline-track { position: absolute; left: 0; right: 0; top: 12px; height: 7px; background: #071b44; border: 1px solid #183f73; }.actual-range { position: absolute; top: -1px; height: 7px; background: #1989ee; box-shadow: 0 0 7px rgba(25,137,238,.6); }.timeline-marker { position: absolute; z-index: 2; top: -5px; width: 1px; height: 17px; background: #54ffff; }.timeline-marker span { position: absolute; left: 50%; top: -17px; transform: translateX(-50%); color: #54ffff; font-size: 9px; white-space: nowrap; }.timeline-empty { position: absolute; top: 3px; left: 50%; transform: translateX(-50%); color: #587fa8; font-size: 10px; }.timeline-labels { position: absolute; left: 0; right: 0; bottom: 0; display: flex; justify-content: space-between; color: #557da7; font-size: 9px; }
|
|
|
.playback-controls { height: 48px; min-height: 48px; padding: 0 9px; display: flex; align-items: center; border: 1px solid rgba(43,133,226,.38); background: #081e53; }.transport { display: flex; align-items: center; gap: 5px; }.playback-controls button { border: 1px solid rgba(53,146,238,.5); color: #a8d2fa; background: #0a2b67; cursor: pointer; }.transport>button { width: 29px; height: 29px; }.transport button.play { width: 35px; height: 35px; border-radius: 50%; color: #fff; background: #176bc2; }.time-readout { width: 170px; display: flex; flex-direction: column; margin-left: 6px; }.time-readout strong { color: #54ffff; font: 13px Consolas,monospace; }.time-readout span { color: #638fb9; font-size: 9px; }.control-center { flex: 1; display: flex; align-items: center; justify-content: center; gap: 18px; }.volume { display: flex; align-items: center; gap: 5px; color: #71a7d7; }.volume input { width: 76px; accent-color: #228ef1; }.speed-group { display: flex; }.speed-group button { width: 34px; height: 27px; border-right: 0; }.speed-group button:last-child { border-right: 1px solid rgba(53,146,238,.5); }.speed-group button.active { color: #fff; background: #176bc2; }.jump-control { display: flex; align-items: center; gap: 5px; color: #729dcc; font-size: 10px; }.jump-control input { width: 100px; height: 27px; padding: 0 5px; border: 1px solid rgba(53,146,238,.5); color: #d1e7ff; background: #081f53; }.jump-control button { height: 27px; }.fullscreen { width: 30px; height: 30px; }
|
|
|
.status-line { min-height: 29px; padding: 0 10px; display: flex; align-items: center; gap: 7px; color: #7fa8d0; border: 1px solid rgba(43,133,226,.25); background: rgba(6,25,70,.6); font-size: 11px; }.status-line i { width: 6px; height: 6px; border-radius: 50%; background: #428fda; }.status-line.success i { background: #2bdb9f; }.status-line.warn i { background: #e5a83d; }.status-line.error i { background: #ff5c72; }
|
|
|
@media (max-width: 1250px) { .query-bar { flex-wrap: wrap; }.query-actions { margin-left: 0; }.control-center { gap: 8px; }.time-readout { width: 130px; } }
|
|
|
</style>
|