瀏覽代碼

fix(cockpit): 修正倍速能力键映射并复位回放能力

ChenJZ 3 小時之前
父節點
當前提交
4cd9fa1c33

+ 5 - 4
src/modules/cockpit/components/VideoMonitorPanel.vue

@@ -89,10 +89,11 @@ const playRecord = (record) => video.play(record.startTime, record.endTime)
 const CONTROL_REASON = '真实设备未确认此控制能力,暂不开放'
 const playbackControls = computed(() => {
     const granted = state.value.playbackControls || {}
-    const control = (key, label) => ({ key, label, enabled: granted[key] === true, reason: CONTROL_REASON })
-    return [control('pause', '暂停'), control('resume', '继续'), control('speed-0.25', '0.25×'),
-        control('speed-0.5', '0.5×'), control('speed-1', '1×'), control('speed-2', '2×'),
-        control('speed-4', '4×'), control('seek', '跳转')]
+    // Speed keys are per-rate in the UI but share one confirmed capability on the server.
+    const control = (key, label, capability = key) => ({ key, label, enabled: granted[capability] === true, reason: CONTROL_REASON })
+    return [control('pause', '暂停'), control('resume', '继续'), control('speed-0.25', '0.25×', 'speed'),
+        control('speed-0.5', '0.5×', 'speed'), control('speed-1', '1×', 'speed'), control('speed-2', '2×', 'speed'),
+        control('speed-4', '4×', 'speed'), control('seek', '跳转')]
 })
 // A disabled control must not reach the server even when activated programmatically.
 const runControl = (control) => { if (control.enabled) void video.controlPlayback(control.key) }

+ 3 - 1
src/modules/cockpit/services/videoApi.js

@@ -80,7 +80,9 @@ export function createVideoApi(options = {}) {
             .then(sessionReply(sessionId, ['PREPARED', 'STARTING', 'ACTIVE', 'CLEANUP_PENDING', 'STOPPED', 'FAILED'])),
         heartbeat: ({ sessionId, sessionToken }) => request(sessionPath(sessionId, 'heartbeat'), { sessionToken }).then(sessionReply(sessionId, 'ACTIVE')),
         stop: ({ sessionId, sessionToken }) => request(sessionPath(sessionId, 'stop'), { sessionToken }).then(sessionReply(sessionId, ['STOPPED', 'FAILED', 'CLEANUP_PENDING'])),
-        control: ({ sessionId, sessionToken }, action) => request(sessionPath(sessionId, 'control'), { sessionToken, action }),
+        control: ({ sessionId, sessionToken }, action, value) => request(sessionPath(sessionId, 'control'),
+            value === undefined ? { sessionToken, action } : { sessionToken, action, value })
+            .then(sessionReply(sessionId, ['ACTIVE'])),
     }
 }
 

+ 18 - 7
src/modules/cockpit/services/videoPlaybackSession.js

@@ -34,6 +34,13 @@ function validate(from, to) {
     return { start, end }
 }
 
+/** Only explicitly confirmed controls are enabled; anything unreported stays unconfirmed. */
+function normalizeControls(reported) {
+    const source = reported && typeof reported === 'object' ? reported : {}
+    return { pause: source.pause === true, resume: source.resume === true,
+        speed: source.speed === true, seek: source.seek === true }
+}
+
 /**
  * History browsing and controlled playback for one anonymous viewer.
  * Records are only ever shown from what the device/NVR returned; playback starts only on an explicit choice.
@@ -99,9 +106,9 @@ export function createVideoPlaybackSession(api, update, timers = globalThis) {
             return false
         }
         identity = { sessionId: started.sessionId, sessionToken: started.sessionToken, deviceCode, mode: 'PLAYBACK',
-            controls: { pause: false, resume: false, speed: false, seek: false } }
+            // A device that reported confirmed controls keeps them; the default confirms nothing.
+            controls: normalizeControls(started.controls) }
         current = identity
-        // Controls stay unconfirmed until a real device proves support; the panel renders them grey.
         publish({ phase: 'playing', media, window: { startTime, endTime }, controls: identity.controls })
         return true
     }
@@ -109,13 +116,16 @@ export function createVideoPlaybackSession(api, update, timers = globalThis) {
     /** Only a control the device confirmed may be sent; everything else is refused locally. */
     async function control(key) {
         const owned = current
-        if (!owned || !/^(pause|resume|seek|speed-(0\.25|0\.5|1|2|4))$/.test(String(key))) return false
+        const match = /^(pause|resume|seek|speed-(0\.25|0\.5|1|2|4))$/.exec(String(key))
+        if (!owned || !match) return false
         const granted = owned.controls || {}
-        const allowed = key === 'pause' ? granted.pause : key === 'resume' ? granted.resume
-            : key === 'seek' ? granted.seek : granted.speed === true
+        const action = match[2] ? 'speed' : match[1]
+        const allowed = action === 'speed' ? granted.speed : granted[action]
         if (allowed !== true) return false
+        // The control API takes an action plus its value; a rate key carries the rate as its value.
+        const value = action === 'speed' ? Number(match[2]) : undefined
         try {
-            await api.control(owned, key)
+            await api.control(owned, action, value)
             return true
         } catch (error) {
             publish({ error: error?.message || '回放控制失败' })
@@ -139,7 +149,8 @@ export function createVideoPlaybackSession(api, update, timers = globalThis) {
         ++generation
         const owned = identity
         identity = null; current = null
-        publish({ media: null, phase: 'idle' })
+        // A released session must not carry its capabilities into the next one.
+        publish({ media: null, phase: 'idle', controls: {} })
         const clean = await release(owned)
         if (!clean) publish({ phase: 'failed', error: cleanupMessage })
         return clean

+ 1 - 1
src/modules/cockpit/stores/videoStore.js

@@ -49,7 +49,7 @@ export const useVideoStore = defineStore('cockpitVideo', () => {
         }
         await session().start(device.deviceCode)
     }
-    async function close() { publish({ open: false, playbackMedia: null }); if (history) await history.dispose(); history = undefined; if (controller) await controller.stop() }
+    async function close() { publish({ open: false, playbackMedia: null, playbackControls: {} }); if (history) await history.dispose(); history = undefined; if (controller) await controller.stop() }
     /** Ends the live stream but keeps the monitor open; tab switches use this instead of close(). */
     async function stopLive() { if (controller) await controller.stop() }
     async function startLive() {

+ 38 - 1
tests/cockpit-video/playback.mjs

@@ -135,7 +135,7 @@ test('an unconfirmed playback control is refused locally without a request', asy
     const sent = []
     const playback = createVideoPlaybackSession({
         records: async () => ({ records: [] }), playback: async () => started('A'),
-        control: async (session, action) => { sent.push(action); return {} },
+        control: async (session, action, value) => { sent.push([action, value]); return {} },
         stop: async (session) => stopped(session),
     }, () => {})
     try {
@@ -151,6 +151,43 @@ test('an unconfirmed playback control is refused locally without a request', asy
     } finally { await playback.dispose() }
 })
 
+test('a confirmed control is forwarded with its action and rate, and a speed rate maps to one capability', async () => {
+    const sent = []
+    let visible = {}
+    const playback = createVideoPlaybackSession({
+        records: async () => ({ records: [] }),
+        // A device that proved pause and speed support, but not seek.
+        playback: async () => ({ ...started('A'), controls: { pause: true, resume: true, speed: true, seek: false } }),
+        control: async (session, action, value) => { sent.push([action, value]); return { sessionId: session.sessionId, status: 'ACTIVE' } },
+        stop: async (session) => stopped(session),
+    }, (patch) => { visible = { ...visible, ...patch } })
+    try {
+        await playback.play('A', '2026-11-10 01:00:00', '2026-11-10 02:00:00')
+        assert.equal(visible.controls.pause, true, 'a confirmed capability must reach the panel')
+        assert.equal(await playback.control('pause'), true)
+        // Each rate key is one capability on the server but carries its own rate value.
+        assert.equal(await playback.control('speed-0.25'), true)
+        assert.equal(await playback.control('speed-4'), true)
+        assert.equal(await playback.control('seek'), false, 'an unconfirmed seek must still be refused')
+        assert.deepEqual(sent, [['pause', undefined], ['speed', 0.25], ['speed', 4]])
+    } finally { await playback.dispose() }
+})
+
+test('stopping resets the playback capability set so it cannot leak into the next session', async () => {
+    let visible = {}
+    const playback = createVideoPlaybackSession({
+        records: async () => ({ records: [] }),
+        playback: async () => ({ ...started('A'), controls: { pause: true, resume: true, speed: true, seek: true } }),
+        stop: async (session) => stopped(session),
+    }, (patch) => { visible = { ...visible, ...patch } })
+    try {
+        await playback.play('A', '2026-11-10 01:00:00', '2026-11-10 02:00:00')
+        assert.equal(visible.controls.pause, true)
+        await playback.stop()
+        assert.deepEqual(visible.controls, {}, 'a released session must not keep its capabilities')
+    } finally { await playback.dispose() }
+})
+
 test('stopping releases the owned playback session and reports unconfirmed cleanup truthfully', async () => {
     const clock = new Clock(), updates = []
     let visible = {}