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.
Files changed (33) hide show
  1. package/.claude/agents/docs-accuracy-reviewer.md +146 -0
  2. package/.claude/agents/een-auth-agent.md +168 -0
  3. package/.claude/agents/een-devices-agent.md +294 -0
  4. package/.claude/agents/een-events-agent.md +375 -0
  5. package/.claude/agents/een-media-agent.md +256 -0
  6. package/.claude/agents/een-setup-agent.md +126 -0
  7. package/.claude/agents/een-users-agent.md +239 -0
  8. package/.claude/agents/test-runner.md +144 -0
  9. package/CHANGELOG.md +138 -30
  10. package/docs/AI-CONTEXT.md +169 -1700
  11. package/docs/ai-reference/AI-AUTH.md +288 -0
  12. package/docs/ai-reference/AI-DEVICES.md +569 -0
  13. package/docs/ai-reference/AI-EVENTS.md +1745 -0
  14. package/docs/ai-reference/AI-MEDIA.md +974 -0
  15. package/docs/ai-reference/AI-SETUP.md +267 -0
  16. package/docs/ai-reference/AI-USERS.md +255 -0
  17. package/examples/vue-event-subscriptions/package-lock.json +8 -1
  18. package/examples/vue-event-subscriptions/package.json +1 -0
  19. package/examples/vue-event-subscriptions/src/App.vue +1 -41
  20. package/examples/vue-event-subscriptions/src/composables/useHlsPlayer.ts +272 -0
  21. package/examples/vue-event-subscriptions/src/main.ts +3 -3
  22. package/examples/vue-event-subscriptions/src/stores/connection.ts +101 -0
  23. package/examples/vue-event-subscriptions/src/stores/mediaSession.ts +79 -0
  24. package/examples/vue-event-subscriptions/src/views/LiveEvents.vue +349 -88
  25. package/examples/vue-event-subscriptions/src/views/Logout.vue +6 -0
  26. package/examples/vue-event-subscriptions/src/views/Subscriptions.vue +0 -13
  27. package/examples/vue-events/package-lock.json +8 -1
  28. package/examples/vue-events/package.json +1 -0
  29. package/examples/vue-events/src/components/EventsModal.vue +269 -47
  30. package/examples/vue-events/src/composables/useHlsPlayer.ts +272 -0
  31. package/examples/vue-events/src/stores/mediaSession.ts +79 -0
  32. package/package.json +10 -2
  33. 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