een-api-toolkit 0.3.20 → 0.3.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/agents/docs-accuracy-reviewer.md +146 -0
- package/.claude/agents/een-auth-agent.md +168 -0
- package/.claude/agents/een-devices-agent.md +294 -0
- package/.claude/agents/een-events-agent.md +375 -0
- package/.claude/agents/een-media-agent.md +256 -0
- package/.claude/agents/een-setup-agent.md +126 -0
- package/.claude/agents/een-users-agent.md +239 -0
- package/.claude/agents/test-runner.md +144 -0
- package/CHANGELOG.md +138 -30
- package/docs/AI-CONTEXT.md +169 -1700
- package/docs/ai-reference/AI-AUTH.md +288 -0
- package/docs/ai-reference/AI-DEVICES.md +569 -0
- package/docs/ai-reference/AI-EVENTS.md +1745 -0
- package/docs/ai-reference/AI-MEDIA.md +974 -0
- package/docs/ai-reference/AI-SETUP.md +267 -0
- package/docs/ai-reference/AI-USERS.md +255 -0
- package/examples/vue-event-subscriptions/package-lock.json +8 -1
- package/examples/vue-event-subscriptions/package.json +1 -0
- package/examples/vue-event-subscriptions/src/App.vue +1 -41
- package/examples/vue-event-subscriptions/src/composables/useHlsPlayer.ts +272 -0
- package/examples/vue-event-subscriptions/src/main.ts +3 -3
- package/examples/vue-event-subscriptions/src/stores/connection.ts +101 -0
- package/examples/vue-event-subscriptions/src/stores/mediaSession.ts +79 -0
- package/examples/vue-event-subscriptions/src/views/LiveEvents.vue +349 -88
- package/examples/vue-event-subscriptions/src/views/Logout.vue +6 -0
- package/examples/vue-event-subscriptions/src/views/Subscriptions.vue +0 -13
- package/examples/vue-events/package-lock.json +8 -1
- package/examples/vue-events/package.json +1 -0
- package/examples/vue-events/src/components/EventsModal.vue +269 -47
- package/examples/vue-events/src/composables/useHlsPlayer.ts +272 -0
- package/examples/vue-events/src/stores/mediaSession.ts +79 -0
- package/package.json +10 -2
- package/scripts/setup-agents.ts +116 -0
|
@@ -0,0 +1,974 @@
|
|
|
1
|
+
# Media & Live Video - EEN API Toolkit
|
|
2
|
+
|
|
3
|
+
> **Version:** 0.3.22
|
|
4
|
+
>
|
|
5
|
+
> Complete reference for media retrieval, live streaming, and video playback.
|
|
6
|
+
> Load this document when implementing video features.
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## CRITICAL: Choosing the Right Approach
|
|
11
|
+
|
|
12
|
+
| Use Case | Method | Why |
|
|
13
|
+
|----------|--------|-----|
|
|
14
|
+
| Thumbnails (20+ cameras) | `getLiveImage()` | Handles auth, returns base64 |
|
|
15
|
+
| Auto-updating preview | `multipartUrl` | Continuous MJPEG stream |
|
|
16
|
+
| Full-quality live video | Live Video SDK | WebCodecs, full resolution |
|
|
17
|
+
| Recorded video playback | HLS via `listMedia()` | Standard video player |
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Common Pitfalls (READ FIRST)
|
|
22
|
+
|
|
23
|
+
### DON'T: Construct API URLs for `<img>` tags
|
|
24
|
+
|
|
25
|
+
```typescript
|
|
26
|
+
// WRONG - browsers cannot send Authorization headers with <img src>
|
|
27
|
+
const url = `${authStore.baseUrl}/api/v3.0/media/liveImage.jpeg?deviceId=${cameraId}`
|
|
28
|
+
imgElement.src = url // Results in 401 Unauthorized
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### DON'T: Modify multipartUrl
|
|
32
|
+
|
|
33
|
+
```typescript
|
|
34
|
+
// WRONG - adding parameters breaks the pre-signed URL
|
|
35
|
+
imgElement.src = `${feedUrl}?timestamp=${Date.now()}` // 400 Bad Request
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### DO: Use `getLiveImage()` for thumbnails
|
|
39
|
+
|
|
40
|
+
```typescript
|
|
41
|
+
// CORRECT - returns base64 data URL
|
|
42
|
+
const { data } = await getLiveImage({ deviceId: cameraId })
|
|
43
|
+
imgElement.src = data.imageData // "data:image/jpeg;base64,..."
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## Media Types
|
|
49
|
+
|
|
50
|
+
```typescript
|
|
51
|
+
type MediaType = 'video' | 'image'
|
|
52
|
+
type MediaStreamType = 'preview' | 'main'
|
|
53
|
+
|
|
54
|
+
interface MediaInterval {
|
|
55
|
+
type: MediaStreamType
|
|
56
|
+
deviceId: string
|
|
57
|
+
mediaType: MediaType
|
|
58
|
+
startTimestamp: string // ISO 8601
|
|
59
|
+
endTimestamp: string // ISO 8601
|
|
60
|
+
hlsUrl?: string
|
|
61
|
+
multipartUrl?: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface ListMediaParams {
|
|
65
|
+
deviceId: string // Required - camera ID
|
|
66
|
+
type: MediaStreamType // 'preview' or 'main'
|
|
67
|
+
mediaType: MediaType // 'video' or 'image'
|
|
68
|
+
startTimestamp: string // ISO 8601 start time
|
|
69
|
+
endTimestamp?: string // ISO 8601 end time
|
|
70
|
+
include?: string[] // e.g., ['hlsUrl', 'multipartUrl']
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface LiveImageResult {
|
|
74
|
+
imageData: string // Base64 data URL
|
|
75
|
+
timestamp: string | null // X-Een-Timestamp header
|
|
76
|
+
prevToken: string | null // For navigation
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface RecordedImageResult {
|
|
80
|
+
imageData: string // Base64 data URL
|
|
81
|
+
timestamp: string | null
|
|
82
|
+
nextToken: string | null // Navigate forward
|
|
83
|
+
prevToken: string | null // Navigate backward
|
|
84
|
+
overlaySvg: string | null // Bounding box overlay
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## Feed Types
|
|
91
|
+
|
|
92
|
+
```typescript
|
|
93
|
+
type FeedStreamType = 'main' | 'preview' | 'talkdown'
|
|
94
|
+
type FeedMediaType = 'video' | 'audio' | 'image'
|
|
95
|
+
|
|
96
|
+
interface Feed {
|
|
97
|
+
id: string
|
|
98
|
+
type: FeedStreamType
|
|
99
|
+
deviceId: string
|
|
100
|
+
mediaType: FeedMediaType
|
|
101
|
+
multipartUrl?: string | null // For MJPEG streaming
|
|
102
|
+
hlsUrl?: string | null // For HLS playback
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
interface ListFeedsParams {
|
|
106
|
+
deviceId?: string
|
|
107
|
+
type?: FeedStreamType
|
|
108
|
+
include?: ('multipartUrl' | 'hlsUrl' | 'rtspUrl')[]
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## Media Functions
|
|
115
|
+
|
|
116
|
+
### getLiveImage(params)
|
|
117
|
+
|
|
118
|
+
Get live preview image. Best for thumbnails.
|
|
119
|
+
|
|
120
|
+
```typescript
|
|
121
|
+
import { getLiveImage } from 'een-api-toolkit'
|
|
122
|
+
|
|
123
|
+
const { data, error } = await getLiveImage({ deviceId: 'camera-123' })
|
|
124
|
+
|
|
125
|
+
if (data) {
|
|
126
|
+
imgElement.src = data.imageData // data:image/jpeg;base64,...
|
|
127
|
+
console.log('Timestamp:', data.timestamp)
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### getRecordedImage(params)
|
|
132
|
+
|
|
133
|
+
Get recorded image with navigation.
|
|
134
|
+
|
|
135
|
+
```typescript
|
|
136
|
+
import { getRecordedImage } from 'een-api-toolkit'
|
|
137
|
+
|
|
138
|
+
// Get image at timestamp
|
|
139
|
+
const { data } = await getRecordedImage({
|
|
140
|
+
deviceId: 'camera-123',
|
|
141
|
+
timestamp: '2024-01-15T14:30:00.000+00:00'
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
// Navigate to next image
|
|
145
|
+
if (data.nextToken) {
|
|
146
|
+
const { data: next } = await getRecordedImage({ pageToken: data.nextToken })
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### initMediaSession()
|
|
151
|
+
|
|
152
|
+
Initialize media session for cookie-based auth. Required before using multipartUrl.
|
|
153
|
+
|
|
154
|
+
```typescript
|
|
155
|
+
import { initMediaSession, listFeeds } from 'een-api-toolkit'
|
|
156
|
+
|
|
157
|
+
// Initialize once after login
|
|
158
|
+
await initMediaSession()
|
|
159
|
+
|
|
160
|
+
// Now multipartUrl works in <img> elements
|
|
161
|
+
const { data: feeds } = await listFeeds({
|
|
162
|
+
deviceId: 'camera-123',
|
|
163
|
+
include: ['multipartUrl']
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
imgElement.src = feeds.results[0].multipartUrl
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### listMedia(params)
|
|
170
|
+
|
|
171
|
+
List recording intervals. Use for HLS playback.
|
|
172
|
+
|
|
173
|
+
```typescript
|
|
174
|
+
import { listMedia, formatTimestamp } from 'een-api-toolkit'
|
|
175
|
+
|
|
176
|
+
const { data } = await listMedia({
|
|
177
|
+
deviceId: 'camera-123',
|
|
178
|
+
type: 'main', // MUST be 'main' for HLS
|
|
179
|
+
mediaType: 'video',
|
|
180
|
+
startTimestamp: formatTimestamp(startDate.toISOString()),
|
|
181
|
+
endTimestamp: formatTimestamp(endDate.toISOString()),
|
|
182
|
+
include: ['hlsUrl']
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
// Find interval containing target timestamp
|
|
186
|
+
const interval = data.results.find(i =>
|
|
187
|
+
i.hlsUrl && targetTime >= new Date(i.startTimestamp) && targetTime <= new Date(i.endTimestamp)
|
|
188
|
+
)
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
---
|
|
192
|
+
|
|
193
|
+
## Live Video Streaming
|
|
194
|
+
|
|
195
|
+
### Preview Stream (MJPEG)
|
|
196
|
+
|
|
197
|
+
```typescript
|
|
198
|
+
// 1. Initialize media session
|
|
199
|
+
await initMediaSession()
|
|
200
|
+
|
|
201
|
+
// 2. Get feed with multipartUrl
|
|
202
|
+
const { data: feeds } = await listFeeds({
|
|
203
|
+
deviceId: cameraId,
|
|
204
|
+
type: 'preview',
|
|
205
|
+
include: ['multipartUrl']
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
// 3. Use directly in <img>
|
|
209
|
+
const previewFeed = feeds.results.find(f => f.multipartUrl)
|
|
210
|
+
imgElement.src = previewFeed.multipartUrl
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
### Main Stream (Live Video SDK)
|
|
214
|
+
|
|
215
|
+
```typescript
|
|
216
|
+
import { LivePlayer } from '@een/live-video-web-sdk'
|
|
217
|
+
import { useAuthStore } from 'een-api-toolkit'
|
|
218
|
+
|
|
219
|
+
const authStore = useAuthStore()
|
|
220
|
+
const player = new LivePlayer()
|
|
221
|
+
|
|
222
|
+
player.onStatusChange((status) => {
|
|
223
|
+
console.log('Player status:', status)
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
await player.start({
|
|
227
|
+
videoElement: videoRef.value,
|
|
228
|
+
cameraId: cameraId,
|
|
229
|
+
baseUrl: authStore.baseUrl,
|
|
230
|
+
jwt: authStore.token
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
// Cleanup
|
|
234
|
+
player.stop()
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
---
|
|
238
|
+
|
|
239
|
+
## HLS Playback
|
|
240
|
+
|
|
241
|
+
```typescript
|
|
242
|
+
import Hls from 'hls.js'
|
|
243
|
+
import { useAuthStore } from 'een-api-toolkit'
|
|
244
|
+
|
|
245
|
+
const authStore = useAuthStore()
|
|
246
|
+
|
|
247
|
+
const hls = new Hls({
|
|
248
|
+
xhrSetup: (xhr) => {
|
|
249
|
+
// MUST use Authorization header, not withCredentials
|
|
250
|
+
xhr.setRequestHeader('Authorization', `Bearer ${authStore.token}`)
|
|
251
|
+
}
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
hls.loadSource(hlsUrl)
|
|
255
|
+
hls.attachMedia(videoElement)
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
---
|
|
259
|
+
|
|
260
|
+
## Utility: formatTimestamp
|
|
261
|
+
|
|
262
|
+
EEN API requires `+00:00` format, not `Z`:
|
|
263
|
+
|
|
264
|
+
```typescript
|
|
265
|
+
import { formatTimestamp } from 'een-api-toolkit'
|
|
266
|
+
|
|
267
|
+
// Convert Z to +00:00
|
|
268
|
+
formatTimestamp('2025-01-15T22:30:00.000Z')
|
|
269
|
+
// Returns: '2025-01-15T22:30:00.000+00:00'
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
---
|
|
273
|
+
|
|
274
|
+
## Vue Components
|
|
275
|
+
|
|
276
|
+
### LiveCamera.vue
|
|
277
|
+
|
|
278
|
+
```vue
|
|
279
|
+
<script setup lang="ts">
|
|
280
|
+
import { ref, onMounted, onUnmounted } from 'vue'
|
|
281
|
+
import { getCameras, getLiveImage } from 'een-api-toolkit'
|
|
282
|
+
import type { Camera } from 'een-api-toolkit'
|
|
283
|
+
import { useSelectedCamera } from '../composables/useSelectedCamera'
|
|
284
|
+
import { formatTimestampLocale, formatTimestampUtc } from '../utils/timestamp'
|
|
285
|
+
|
|
286
|
+
const cameras = ref<Camera[]>([])
|
|
287
|
+
const { selectedCameraId, setSelectedCamera } = useSelectedCamera()
|
|
288
|
+
const imageData = ref<string | null>(null)
|
|
289
|
+
const imageTimestamp = ref<string | null>(null)
|
|
290
|
+
const loading = ref(true)
|
|
291
|
+
const loadingImage = ref(false)
|
|
292
|
+
const error = ref<string | null>(null)
|
|
293
|
+
const refreshInterval = ref<ReturnType<typeof setInterval> | null>(null)
|
|
294
|
+
const autoRefresh = ref(true)
|
|
295
|
+
|
|
296
|
+
// Configurable refresh interval (in milliseconds)
|
|
297
|
+
const REFRESH_INTERVAL_MS = 5000
|
|
298
|
+
|
|
299
|
+
// Error recovery: track consecutive failures for backoff
|
|
300
|
+
const consecutiveErrors = ref(0)
|
|
301
|
+
const MAX_CONSECUTIVE_ERRORS = 3
|
|
302
|
+
|
|
303
|
+
// Track component lifecycle to prevent memory leaks
|
|
304
|
+
const isMounted = ref(true)
|
|
305
|
+
|
|
306
|
+
// Track current request to handle race conditions during camera switching
|
|
307
|
+
let currentRequestId = 0
|
|
308
|
+
|
|
309
|
+
async function loadCameras() {
|
|
310
|
+
loading.value = true
|
|
311
|
+
error.value = null
|
|
312
|
+
|
|
313
|
+
const result = await getCameras()
|
|
314
|
+
|
|
315
|
+
// Check if component is still mounted
|
|
316
|
+
if (!isMounted.value) return
|
|
317
|
+
|
|
318
|
+
if (result.error) {
|
|
319
|
+
error.value = result.error.message
|
|
320
|
+
loading.value = false
|
|
321
|
+
return
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
cameras.value = result.data?.results || []
|
|
325
|
+
loading.value = false
|
|
326
|
+
|
|
327
|
+
// Use shared camera if valid, otherwise auto-select first camera
|
|
328
|
+
if (cameras.value.length > 0) {
|
|
329
|
+
const isValidCamera = selectedCameraId.value &&
|
|
330
|
+
cameras.value.some(c => c.id === selectedCameraId.value)
|
|
331
|
+
if (!isValidCamera) {
|
|
332
|
+
setSelectedCamera(cameras.value[0].id)
|
|
333
|
+
}
|
|
334
|
+
await fetchLiveImage()
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async function fetchLiveImage() {
|
|
339
|
+
if (!selectedCameraId.value) return
|
|
340
|
+
|
|
341
|
+
// Increment request ID to track this specific request
|
|
342
|
+
const requestId = ++currentRequestId
|
|
343
|
+
const cameraId = selectedCameraId.value
|
|
344
|
+
|
|
345
|
+
loadingImage.value = true
|
|
346
|
+
|
|
347
|
+
const result = await getLiveImage({ deviceId: cameraId })
|
|
348
|
+
|
|
349
|
+
// Check if component is still mounted and this is still the current request
|
|
350
|
+
if (!isMounted.value || requestId !== currentRequestId) {
|
|
351
|
+
return // Discard stale response
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (result.error) {
|
|
355
|
+
error.value = result.error.message
|
|
356
|
+
loadingImage.value = false
|
|
357
|
+
consecutiveErrors.value++
|
|
358
|
+
|
|
359
|
+
// Stop auto-refresh after too many consecutive errors
|
|
360
|
+
if (consecutiveErrors.value >= MAX_CONSECUTIVE_ERRORS && autoRefresh.value) {
|
|
361
|
+
autoRefresh.value = false
|
|
362
|
+
stopAutoRefresh()
|
|
363
|
+
error.value = `${result.error.message} (Auto-refresh stopped after ${MAX_CONSECUTIVE_ERRORS} failures)`
|
|
364
|
+
}
|
|
365
|
+
return
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Reset error count on success
|
|
369
|
+
consecutiveErrors.value = 0
|
|
370
|
+
error.value = null
|
|
371
|
+
|
|
372
|
+
if (result.data) {
|
|
373
|
+
imageData.value = result.data.imageData
|
|
374
|
+
imageTimestamp.value = result.data.timestamp
|
|
375
|
+
}
|
|
376
|
+
loadingImage.value = false
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async function selectCamera(cameraId: string) {
|
|
380
|
+
setSelectedCamera(cameraId)
|
|
381
|
+
imageData.value = null
|
|
382
|
+
error.value = null
|
|
383
|
+
consecutiveErrors.value = 0
|
|
384
|
+
await fetchLiveImage()
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function startAutoRefresh() {
|
|
388
|
+
if (refreshInterval.value) return
|
|
389
|
+
|
|
390
|
+
consecutiveErrors.value = 0
|
|
391
|
+
refreshInterval.value = setInterval(async () => {
|
|
392
|
+
if (autoRefresh.value && selectedCameraId.value && isMounted.value) {
|
|
393
|
+
try {
|
|
394
|
+
await fetchLiveImage()
|
|
395
|
+
} catch (err) {
|
|
396
|
+
// Catch any unexpected errors to prevent interval from breaking
|
|
397
|
+
console.error('Auto-refresh error:', err)
|
|
398
|
+
consecutiveErrors.value++
|
|
399
|
+
if (consecutiveErrors.value >= MAX_CONSECUTIVE_ERRORS) {
|
|
400
|
+
autoRefresh.value = false
|
|
401
|
+
stopAutoRefresh()
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}, REFRESH_INTERVAL_MS)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function stopAutoRefresh() {
|
|
409
|
+
if (refreshInterval.value) {
|
|
410
|
+
clearInterval(refreshInterval.value)
|
|
411
|
+
refreshInterval.value = null
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function toggleAutoRefresh() {
|
|
416
|
+
autoRefresh.value = !autoRefresh.value
|
|
417
|
+
if (autoRefresh.value) {
|
|
418
|
+
consecutiveErrors.value = 0
|
|
419
|
+
startAutoRefresh()
|
|
420
|
+
} else {
|
|
421
|
+
stopAutoRefresh()
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
onMounted(() => {
|
|
426
|
+
loadCameras()
|
|
427
|
+
startAutoRefresh()
|
|
428
|
+
})
|
|
429
|
+
|
|
430
|
+
onUnmounted(() => {
|
|
431
|
+
isMounted.value = false
|
|
432
|
+
stopAutoRefresh()
|
|
433
|
+
})
|
|
434
|
+
</script>
|
|
435
|
+
|
|
436
|
+
<template>
|
|
437
|
+
<div class="live-camera">
|
|
438
|
+
<h2>Live Camera Image (preview)</h2>
|
|
439
|
+
|
|
440
|
+
<div v-if="loading" class="loading">
|
|
441
|
+
<p>Loading cameras...</p>
|
|
442
|
+
</div>
|
|
443
|
+
|
|
444
|
+
<div v-else-if="error && cameras.length === 0" class="error-state">
|
|
445
|
+
<p class="error">{{ error }}</p>
|
|
446
|
+
<button @click="loadCameras">Retry</button>
|
|
447
|
+
</div>
|
|
448
|
+
|
|
449
|
+
<div v-else-if="cameras.length === 0" class="no-cameras">
|
|
450
|
+
<p>No cameras found in your account.</p>
|
|
451
|
+
</div>
|
|
452
|
+
|
|
453
|
+
<div v-else class="camera-view">
|
|
454
|
+
<div class="camera-selector">
|
|
455
|
+
<label for="camera-select">Select Camera:</label>
|
|
456
|
+
<select
|
|
457
|
+
id="camera-select"
|
|
458
|
+
:value="selectedCameraId"
|
|
459
|
+
@change="selectCamera(($event.target as HTMLSelectElement).value)"
|
|
460
|
+
data-testid="camera-select"
|
|
461
|
+
>
|
|
462
|
+
<option v-for="camera in cameras" :key="camera.id" :value="camera.id">
|
|
463
|
+
{{ camera.name || camera.id }}
|
|
464
|
+
</option>
|
|
465
|
+
</select>
|
|
466
|
+
</div>
|
|
467
|
+
|
|
468
|
+
<div class="controls">
|
|
469
|
+
<button @click="fetchLiveImage" :disabled="loadingImage" data-testid="refresh-button">
|
|
470
|
+
{{ loadingImage ? 'Loading...' : 'Refresh' }}
|
|
471
|
+
</button>
|
|
472
|
+
<button @click="toggleAutoRefresh" data-testid="auto-refresh-button">
|
|
473
|
+
{{ autoRefresh ? 'Stop Auto-Refresh' : 'Start Auto-Refresh' }}
|
|
474
|
+
</button>
|
|
475
|
+
</div>
|
|
476
|
+
|
|
477
|
+
<div v-if="error" class="error-banner">
|
|
478
|
+
<p class="error">{{ error }}</p>
|
|
479
|
+
</div>
|
|
480
|
+
|
|
481
|
+
<div class="image-container" data-testid="image-container">
|
|
482
|
+
<div v-if="loadingImage && !imageData" class="image-loading">
|
|
483
|
+
<p>Loading image...</p>
|
|
484
|
+
</div>
|
|
485
|
+
|
|
486
|
+
<img
|
|
487
|
+
v-else-if="imageData"
|
|
488
|
+
:src="imageData"
|
|
489
|
+
alt="Live camera image"
|
|
490
|
+
class="live-image"
|
|
491
|
+
data-testid="live-image"
|
|
492
|
+
/>
|
|
493
|
+
|
|
494
|
+
<div v-else class="no-image">
|
|
495
|
+
<p>No image available</p>
|
|
496
|
+
</div>
|
|
497
|
+
</div>
|
|
498
|
+
|
|
499
|
+
<div v-if="imageTimestamp" class="timestamp" data-testid="timestamp">
|
|
500
|
+
<small>Timestamp: {{ formatTimestampLocale(imageTimestamp) }}</small>
|
|
501
|
+
<br />
|
|
502
|
+
<small data-testid="utc-timestamp">Timestamp for API (UTC): <span class="utc-timestamp">{{ formatTimestampUtc(imageTimestamp) }}</span></small>
|
|
503
|
+
</div>
|
|
504
|
+
</div>
|
|
505
|
+
|
|
506
|
+
</div>
|
|
507
|
+
</template>
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
### Feeds.vue
|
|
511
|
+
|
|
512
|
+
```vue
|
|
513
|
+
<script setup lang="ts">
|
|
514
|
+
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
|
|
515
|
+
import { getCameras, listFeeds, initMediaSession, useAuthStore } from 'een-api-toolkit'
|
|
516
|
+
import type { Camera, Feed, FeedIncludeOption } from 'een-api-toolkit'
|
|
517
|
+
import LivePlayer from '@een/live-video-web-sdk'
|
|
518
|
+
|
|
519
|
+
const authStore = useAuthStore()
|
|
520
|
+
|
|
521
|
+
const cameras = ref<Camera[]>([])
|
|
522
|
+
const selectedCameraId = ref<string | null>(null)
|
|
523
|
+
const feeds = ref<Feed[]>([])
|
|
524
|
+
const loading = ref(true)
|
|
525
|
+
const loadingFeeds = ref(false)
|
|
526
|
+
const error = ref<string | null>(null)
|
|
527
|
+
|
|
528
|
+
// Modal state
|
|
529
|
+
const showModal = ref(false)
|
|
530
|
+
const selectedFeed = ref<Feed | null>(null)
|
|
531
|
+
const mediaSessionInitialized = ref(false)
|
|
532
|
+
const mediaSessionError = ref<string | null>(null)
|
|
533
|
+
const modalError = ref<string | null>(null)
|
|
534
|
+
|
|
535
|
+
// Player mode: 'preview' | 'live'
|
|
536
|
+
type PlayerMode = 'preview' | 'live'
|
|
537
|
+
const playerMode = ref<PlayerMode>('preview')
|
|
538
|
+
|
|
539
|
+
// Video element ref
|
|
540
|
+
const videoRef = ref<HTMLVideoElement | null>(null)
|
|
541
|
+
|
|
542
|
+
// Live SDK player state
|
|
543
|
+
let livePlayerInstance: LivePlayer | null = null
|
|
544
|
+
const livePlayerLoading = ref(false)
|
|
545
|
+
const livePlayerConnected = ref(false)
|
|
546
|
+
|
|
547
|
+
// Track component lifecycle to prevent memory leaks
|
|
548
|
+
const isMounted = ref(true)
|
|
549
|
+
|
|
550
|
+
// Track current request to handle race conditions
|
|
551
|
+
let currentRequestId = 0
|
|
552
|
+
|
|
553
|
+
// AbortController for cancelling in-flight requests
|
|
554
|
+
let abortController: AbortController | null = null
|
|
555
|
+
|
|
556
|
+
// URL field labels for display (data-driven approach)
|
|
557
|
+
// Uses FeedIncludeOption type to ensure only URL fields are included, not other Feed properties
|
|
558
|
+
const URL_LABELS: Record<FeedIncludeOption, string> = {
|
|
559
|
+
hlsUrl: 'HLS',
|
|
560
|
+
multipartUrl: 'Multipart',
|
|
561
|
+
flvUrl: 'FLV',
|
|
562
|
+
rtspUrl: 'RTSP',
|
|
563
|
+
rtspsUrl: 'RTSPS',
|
|
564
|
+
localRtspUrl: 'Local RTSP',
|
|
565
|
+
webRtcUrl: 'WebRTC',
|
|
566
|
+
audioPushHttpsUrl: 'Audio Push'
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
async function loadCameras() {
|
|
570
|
+
loading.value = true
|
|
571
|
+
error.value = null
|
|
572
|
+
|
|
573
|
+
const result = await getCameras()
|
|
574
|
+
|
|
575
|
+
if (!isMounted.value) return
|
|
576
|
+
|
|
577
|
+
if (result.error) {
|
|
578
|
+
error.value = result.error.message
|
|
579
|
+
loading.value = false
|
|
580
|
+
return
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
cameras.value = result.data?.results || []
|
|
584
|
+
loading.value = false
|
|
585
|
+
|
|
586
|
+
// Auto-select first camera
|
|
587
|
+
if (cameras.value.length > 0 && !selectedCameraId.value) {
|
|
588
|
+
selectedCameraId.value = cameras.value[0].id
|
|
589
|
+
await fetchFeeds()
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
async function fetchFeeds() {
|
|
594
|
+
if (!selectedCameraId.value) return
|
|
595
|
+
|
|
596
|
+
// Cancel any in-flight request
|
|
597
|
+
if (abortController) {
|
|
598
|
+
abortController.abort()
|
|
599
|
+
}
|
|
600
|
+
abortController = new AbortController()
|
|
601
|
+
|
|
602
|
+
const requestId = ++currentRequestId
|
|
603
|
+
loadingFeeds.value = true
|
|
604
|
+
error.value = null
|
|
605
|
+
|
|
606
|
+
const result = await listFeeds({
|
|
607
|
+
deviceId: selectedCameraId.value,
|
|
608
|
+
include: ['hlsUrl', 'multipartUrl', 'flvUrl', 'rtspUrl'],
|
|
609
|
+
signal: abortController.signal
|
|
610
|
+
})
|
|
611
|
+
|
|
612
|
+
// Guard against stale responses:
|
|
613
|
+
// - isMounted check prevents memory leaks by not updating state after unmount
|
|
614
|
+
// - requestId check prevents race conditions when rapid camera switching causes
|
|
615
|
+
// overlapping requests where an older response arrives after a newer one
|
|
616
|
+
if (!isMounted.value || requestId !== currentRequestId) {
|
|
617
|
+
return
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
loadingFeeds.value = false
|
|
621
|
+
|
|
622
|
+
if (result.error) {
|
|
623
|
+
error.value = result.error.message
|
|
624
|
+
feeds.value = []
|
|
625
|
+
return
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
feeds.value = result.data?.results || []
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
async function selectCamera(cameraId: string) {
|
|
632
|
+
if (!isMounted.value) return
|
|
633
|
+
|
|
634
|
+
selectedCameraId.value = cameraId
|
|
635
|
+
feeds.value = []
|
|
636
|
+
error.value = null
|
|
637
|
+
await fetchFeeds()
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function handleCameraChange(event: Event) {
|
|
641
|
+
const target = event.target as HTMLSelectElement
|
|
642
|
+
if (target.value) {
|
|
643
|
+
// Defensive error handling - selectCamera handles errors internally via fetchFeeds,
|
|
644
|
+
// but we catch here to handle any unexpected errors during the camera change flow
|
|
645
|
+
selectCamera(target.value).catch((err) => {
|
|
646
|
+
error.value = `Failed to select camera: ${String(err)}`
|
|
647
|
+
})
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function getAvailableUrls(feed: Feed): string[] {
|
|
652
|
+
return (Object.keys(URL_LABELS) as FeedIncludeOption[])
|
|
653
|
+
.filter(key => feed[key])
|
|
654
|
+
.map(key => URL_LABELS[key])
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// Initialize media session for cookie-based authentication
|
|
658
|
+
async function initializeMediaSession() {
|
|
659
|
+
if (mediaSessionInitialized.value) return true
|
|
660
|
+
|
|
661
|
+
mediaSessionError.value = null
|
|
662
|
+
const result = await initMediaSession()
|
|
663
|
+
|
|
664
|
+
if (result.error) {
|
|
665
|
+
mediaSessionError.value = result.error.message
|
|
666
|
+
return false
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
mediaSessionInitialized.value = true
|
|
670
|
+
return true
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// Check if feed supports multipart preview
|
|
674
|
+
function hasMultipartUrl(feed: Feed): boolean {
|
|
675
|
+
return !!feed.multipartUrl
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// Check if feed should use multipart preview
|
|
679
|
+
function isPreviewFeed(feed: Feed): boolean {
|
|
680
|
+
return feed.type === 'preview' && hasMultipartUrl(feed)
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// Check if feed supports Live SDK (main feed - uses deviceId)
|
|
684
|
+
function supportsLiveSdk(feed: Feed): boolean {
|
|
685
|
+
return feed.type === 'main' && !!feed.deviceId
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// Initialize Live SDK player for a feed
|
|
689
|
+
async function initLivePlayer(feed: Feed) {
|
|
690
|
+
if (!feed.deviceId || !videoRef.value) return
|
|
691
|
+
|
|
692
|
+
// Verify auth is available
|
|
693
|
+
if (!authStore.baseUrl || !authStore.token) {
|
|
694
|
+
modalError.value = 'Authentication required for Live SDK'
|
|
695
|
+
return
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// Validate base URL format
|
|
699
|
+
if (!authStore.baseUrl.startsWith('https://')) {
|
|
700
|
+
modalError.value = 'Invalid base URL format - HTTPS required'
|
|
701
|
+
return
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// Clean up any existing player
|
|
705
|
+
destroyLivePlayer()
|
|
706
|
+
|
|
707
|
+
livePlayerLoading.value = true
|
|
708
|
+
livePlayerConnected.value = false
|
|
709
|
+
|
|
710
|
+
try {
|
|
711
|
+
const videoElement = videoRef.value
|
|
712
|
+
|
|
713
|
+
const config = {
|
|
714
|
+
videoElement,
|
|
715
|
+
cameraId: feed.deviceId,
|
|
716
|
+
baseUrl: authStore.baseUrl,
|
|
717
|
+
jwt: authStore.token
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
livePlayerInstance = new LivePlayer()
|
|
721
|
+
await livePlayerInstance.start(config)
|
|
722
|
+
|
|
723
|
+
livePlayerConnected.value = true
|
|
724
|
+
} catch (err) {
|
|
725
|
+
modalError.value = `Live SDK Error: ${String(err)}`
|
|
726
|
+
livePlayerConnected.value = false
|
|
727
|
+
} finally {
|
|
728
|
+
livePlayerLoading.value = false
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// Destroy Live SDK player instance
|
|
733
|
+
function destroyLivePlayer() {
|
|
734
|
+
if (livePlayerInstance) {
|
|
735
|
+
try {
|
|
736
|
+
livePlayerInstance.stop()
|
|
737
|
+
} catch (err) {
|
|
738
|
+
// Log cleanup errors for debugging, but don't throw
|
|
739
|
+
console.warn('Error while stopping live player:', err)
|
|
740
|
+
}
|
|
741
|
+
livePlayerInstance = null
|
|
742
|
+
}
|
|
743
|
+
livePlayerLoading.value = false
|
|
744
|
+
livePlayerConnected.value = false
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// Clean up all players
|
|
748
|
+
function destroyAllPlayers() {
|
|
749
|
+
destroyLivePlayer()
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// Handle video element errors
|
|
753
|
+
function handleVideoError() {
|
|
754
|
+
modalError.value = 'Video playback error occurred'
|
|
755
|
+
livePlayerConnected.value = false
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// Open the live preview modal for a feed
|
|
759
|
+
async function openFeedPreview(feed: Feed, mode: PlayerMode = 'preview') {
|
|
760
|
+
// Clear any previous modal error
|
|
761
|
+
modalError.value = null
|
|
762
|
+
|
|
763
|
+
// Validate mode is supported for this feed
|
|
764
|
+
if (mode === 'preview' && !isPreviewFeed(feed)) {
|
|
765
|
+
error.value = 'This feed does not support preview mode'
|
|
766
|
+
return
|
|
767
|
+
}
|
|
768
|
+
if (mode === 'live' && !supportsLiveSdk(feed)) {
|
|
769
|
+
error.value = 'This feed does not support Live SDK'
|
|
770
|
+
return
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// For preview mode, initialize media session
|
|
774
|
+
if (mode === 'preview') {
|
|
775
|
+
const initialized = await initializeMediaSession()
|
|
776
|
+
if (!initialized) {
|
|
777
|
+
error.value = mediaSessionError.value || 'Failed to initialize media session'
|
|
778
|
+
return
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
playerMode.value = mode
|
|
783
|
+
selectedFeed.value = feed
|
|
784
|
+
showModal.value = true
|
|
785
|
+
|
|
786
|
+
// For live mode, initialize player after modal is shown
|
|
787
|
+
if (mode === 'live') {
|
|
788
|
+
await nextTick()
|
|
789
|
+
await initLivePlayer(feed)
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// Close the modal
|
|
794
|
+
function closeModal() {
|
|
795
|
+
destroyAllPlayers()
|
|
796
|
+
showModal.value = false
|
|
797
|
+
selectedFeed.value = null
|
|
798
|
+
playerMode.value = 'preview'
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// Handle clicking outside the modal to close it
|
|
802
|
+
function handleModalBackdropClick(event: Event) {
|
|
803
|
+
if ((event.target as HTMLElement).classList.contains('modal-overlay')) {
|
|
804
|
+
closeModal()
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// Handle escape key to close modal
|
|
809
|
+
function handleKeydown(event: KeyboardEvent) {
|
|
810
|
+
if (event.key === 'Escape' && showModal.value) {
|
|
811
|
+
closeModal()
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
onMounted(() => {
|
|
816
|
+
loadCameras()
|
|
817
|
+
window.addEventListener('keydown', handleKeydown)
|
|
818
|
+
})
|
|
819
|
+
|
|
820
|
+
onUnmounted(() => {
|
|
821
|
+
isMounted.value = false
|
|
822
|
+
// Cancel any in-flight request on unmount
|
|
823
|
+
if (abortController) {
|
|
824
|
+
abortController.abort()
|
|
825
|
+
}
|
|
826
|
+
// Clean up all players
|
|
827
|
+
destroyAllPlayers()
|
|
828
|
+
// Remove keydown listener
|
|
829
|
+
window.removeEventListener('keydown', handleKeydown)
|
|
830
|
+
})
|
|
831
|
+
</script>
|
|
832
|
+
|
|
833
|
+
<template>
|
|
834
|
+
<div class="feeds-view">
|
|
835
|
+
<h2>Camera Feeds</h2>
|
|
836
|
+
|
|
837
|
+
<div v-if="loading" class="loading">
|
|
838
|
+
<p>Loading cameras...</p>
|
|
839
|
+
</div>
|
|
840
|
+
|
|
841
|
+
<div v-else-if="error && cameras.length === 0" class="error-state">
|
|
842
|
+
<p class="error">{{ error }}</p>
|
|
843
|
+
<button @click="loadCameras">Retry</button>
|
|
844
|
+
</div>
|
|
845
|
+
|
|
846
|
+
<div v-else-if="cameras.length === 0" class="no-cameras">
|
|
847
|
+
<p>No cameras found in your account.</p>
|
|
848
|
+
</div>
|
|
849
|
+
|
|
850
|
+
<div v-else class="feeds-content">
|
|
851
|
+
<div class="camera-selector">
|
|
852
|
+
<label for="camera-select">Select Camera:</label>
|
|
853
|
+
<select
|
|
854
|
+
id="camera-select"
|
|
855
|
+
:value="selectedCameraId"
|
|
856
|
+
@change="handleCameraChange"
|
|
857
|
+
data-testid="camera-select"
|
|
858
|
+
aria-label="Select a camera to view its feeds"
|
|
859
|
+
>
|
|
860
|
+
<option v-for="camera in cameras" :key="camera.id" :value="camera.id">
|
|
861
|
+
{{ camera.name || camera.id }}
|
|
862
|
+
</option>
|
|
863
|
+
</select>
|
|
864
|
+
<button
|
|
865
|
+
@click="fetchFeeds"
|
|
866
|
+
:disabled="loadingFeeds"
|
|
867
|
+
data-testid="refresh-button"
|
|
868
|
+
aria-label="Refresh feeds list"
|
|
869
|
+
>
|
|
870
|
+
Refresh
|
|
871
|
+
</button>
|
|
872
|
+
</div>
|
|
873
|
+
|
|
874
|
+
<div v-if="error" class="error-banner">
|
|
875
|
+
<p class="error">{{ error }}</p>
|
|
876
|
+
</div>
|
|
877
|
+
|
|
878
|
+
<div class="feeds-list" data-testid="feeds-list">
|
|
879
|
+
<div v-if="loadingFeeds" class="loading">
|
|
880
|
+
<p>Loading feeds...</p>
|
|
881
|
+
</div>
|
|
882
|
+
|
|
883
|
+
<div v-else-if="feeds.length === 0" class="no-feeds">
|
|
884
|
+
<p>No feeds available for this camera.</p>
|
|
885
|
+
</div>
|
|
886
|
+
|
|
887
|
+
<table v-else class="feeds-table" data-testid="feeds-table">
|
|
888
|
+
<thead>
|
|
889
|
+
<tr>
|
|
890
|
+
<th>Feed ID</th>
|
|
891
|
+
<th>Type</th>
|
|
892
|
+
<th>Media Type</th>
|
|
893
|
+
<th>Available URLs</th>
|
|
894
|
+
<th>Preview</th>
|
|
895
|
+
</tr>
|
|
896
|
+
</thead>
|
|
897
|
+
<tbody>
|
|
898
|
+
<tr v-for="feed in feeds" :key="feed.id" data-testid="feed-row">
|
|
899
|
+
<td data-testid="feed-id">{{ feed.id }}</td>
|
|
900
|
+
<td data-testid="feed-type">
|
|
901
|
+
<span :class="['type-badge', `type-${feed.type}`]">
|
|
902
|
+
{{ feed.type }}
|
|
903
|
+
</span>
|
|
904
|
+
</td>
|
|
905
|
+
<td data-testid="feed-media-type">{{ feed.mediaType }}</td>
|
|
906
|
+
<td data-testid="feed-urls">
|
|
907
|
+
<span v-if="getAvailableUrls(feed).length > 0" class="url-list">
|
|
908
|
+
{{ getAvailableUrls(feed).join(', ') }}
|
|
909
|
+
</span>
|
|
910
|
+
<span v-else class="no-urls">None</span>
|
|
911
|
+
</td>
|
|
912
|
+
<td data-testid="feed-preview">
|
|
913
|
+
<div class="button-group-cell">
|
|
914
|
+
<!-- Preview button for preview feeds -->
|
|
915
|
+
<button
|
|
916
|
+
v-if="isPreviewFeed(feed)"
|
|
917
|
+
@click="openFeedPreview(feed, 'preview')"
|
|
918
|
+
class="view-button"
|
|
919
|
+
data-testid="view-preview-button"
|
|
920
|
+
title="Multipart preview stream"
|
|
921
|
+
>
|
|
922
|
+
View
|
|
923
|
+
</button>
|
|
924
|
+
<!-- Live SDK button for main feeds -->
|
|
925
|
+
<button
|
|
926
|
+
v-if="supportsLiveSdk(feed)"
|
|
927
|
+
@click="openFeedPreview(feed, 'live')"
|
|
928
|
+
class="view-button live-button"
|
|
929
|
+
data-testid="view-live-button"
|
|
930
|
+
title="Live Video SDK (WebCodecs)"
|
|
931
|
+
>
|
|
932
|
+
Live
|
|
933
|
+
</button>
|
|
934
|
+
<span v-if="!isPreviewFeed(feed) && !supportsLiveSdk(feed)" class="no-preview">-</span>
|
|
935
|
+
</div>
|
|
936
|
+
</td>
|
|
937
|
+
</tr>
|
|
938
|
+
</tbody>
|
|
939
|
+
</table>
|
|
940
|
+
</div>
|
|
941
|
+
|
|
942
|
+
<div v-if="feeds.length > 0" class="feeds-summary" data-testid="feeds-summary">
|
|
943
|
+
<small>Total feeds: {{ feeds.length }}</small>
|
|
944
|
+
</div>
|
|
945
|
+
</div>
|
|
946
|
+
|
|
947
|
+
<div class="navigation">
|
|
948
|
+
<router-link to="/">
|
|
949
|
+
<button>Back to Home</button>
|
|
950
|
+
</router-link>
|
|
951
|
+
<router-link to="/logout">
|
|
952
|
+
<button>Logout</button>
|
|
953
|
+
</router-link>
|
|
954
|
+
</div>
|
|
955
|
+
|
|
956
|
+
<!-- Live Preview Modal -->
|
|
957
|
+
<div
|
|
958
|
+
v-if="showModal && selectedFeed"
|
|
959
|
+
class="modal-overlay"
|
|
960
|
+
@click="handleModalBackdropClick"
|
|
961
|
+
data-testid="preview-modal"
|
|
962
|
+
>
|
|
963
|
+
<div class="modal-content">
|
|
964
|
+
<div class="modal-header">
|
|
965
|
+
<h3>
|
|
966
|
+
<template v-if="playerMode === 'live'">Live Stream (SDK)</template>
|
|
967
|
+
```
|
|
968
|
+
|
|
969
|
+
---
|
|
970
|
+
|
|
971
|
+
## Reference Examples
|
|
972
|
+
|
|
973
|
+
- `examples/vue-media/` - Live and recorded images, HLS
|
|
974
|
+
- `examples/vue-feeds/` - Preview and main streams
|