een-api-toolkit 0.3.16 → 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 (53) 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 +151 -10
  10. package/README.md +1 -0
  11. package/dist/index.cjs +3 -1
  12. package/dist/index.cjs.map +1 -1
  13. package/dist/index.d.ts +561 -0
  14. package/dist/index.js +483 -260
  15. package/dist/index.js.map +1 -1
  16. package/docs/AI-CONTEXT.md +128 -1648
  17. package/docs/ai-reference/AI-AUTH.md +288 -0
  18. package/docs/ai-reference/AI-DEVICES.md +569 -0
  19. package/docs/ai-reference/AI-EVENTS.md +1745 -0
  20. package/docs/ai-reference/AI-MEDIA.md +974 -0
  21. package/docs/ai-reference/AI-SETUP.md +267 -0
  22. package/docs/ai-reference/AI-USERS.md +255 -0
  23. package/examples/vue-event-subscriptions/.env.example +15 -0
  24. package/examples/vue-event-subscriptions/README.md +103 -0
  25. package/examples/vue-event-subscriptions/e2e/app.spec.ts +71 -0
  26. package/examples/vue-event-subscriptions/e2e/auth.spec.ts +290 -0
  27. package/examples/vue-event-subscriptions/index.html +13 -0
  28. package/examples/vue-event-subscriptions/package-lock.json +1726 -0
  29. package/examples/vue-event-subscriptions/package.json +29 -0
  30. package/examples/vue-event-subscriptions/playwright.config.ts +47 -0
  31. package/examples/vue-event-subscriptions/src/App.vue +193 -0
  32. package/examples/vue-event-subscriptions/src/composables/useHlsPlayer.ts +272 -0
  33. package/examples/vue-event-subscriptions/src/main.ts +25 -0
  34. package/examples/vue-event-subscriptions/src/router/index.ts +68 -0
  35. package/examples/vue-event-subscriptions/src/stores/connection.ts +101 -0
  36. package/examples/vue-event-subscriptions/src/stores/mediaSession.ts +79 -0
  37. package/examples/vue-event-subscriptions/src/views/Callback.vue +76 -0
  38. package/examples/vue-event-subscriptions/src/views/Home.vue +192 -0
  39. package/examples/vue-event-subscriptions/src/views/LiveEvents.vue +901 -0
  40. package/examples/vue-event-subscriptions/src/views/Login.vue +33 -0
  41. package/examples/vue-event-subscriptions/src/views/Logout.vue +65 -0
  42. package/examples/vue-event-subscriptions/src/views/Subscriptions.vue +389 -0
  43. package/examples/vue-event-subscriptions/src/vite-env.d.ts +12 -0
  44. package/examples/vue-event-subscriptions/tsconfig.json +21 -0
  45. package/examples/vue-event-subscriptions/tsconfig.node.json +10 -0
  46. package/examples/vue-event-subscriptions/vite.config.ts +12 -0
  47. package/examples/vue-events/package-lock.json +8 -1
  48. package/examples/vue-events/package.json +1 -0
  49. package/examples/vue-events/src/components/EventsModal.vue +269 -47
  50. package/examples/vue-events/src/composables/useHlsPlayer.ts +272 -0
  51. package/examples/vue-events/src/stores/mediaSession.ts +79 -0
  52. package/package.json +10 -2
  53. package/scripts/setup-agents.ts +116 -0
@@ -10,6 +10,11 @@ import {
10
10
  type EventType,
11
11
  type EenError
12
12
  } from 'een-api-toolkit'
13
+ import { useHlsPlayer } from '../composables/useHlsPlayer'
14
+
15
+ // Initialize HLS player composable
16
+ const hlsPlayer = useHlsPlayer()
17
+ const { videoUrl, videoError, loadingVideo, loadVideo, resetVideo } = hlsPlayer
13
18
 
14
19
  /**
15
20
  * Bounding box from object detection data.
@@ -82,6 +87,13 @@ const imageLoadingIds = ref<Set<string>>(new Set()) // Track which images are cu
82
87
  const boundingBoxCache = ref<Map<string, BoundingBox[]>>(new Map()) // Cache bounding boxes per event
83
88
  const enlargedEventId = ref<string | null>(null)
84
89
 
90
+ // Lightbox media state
91
+ const showVideo = ref(false)
92
+ const hdImageUrl = ref<string | null>(null)
93
+ const loadingHdImage = ref(false)
94
+ const hdImageError = ref<string | null>(null)
95
+ const currentMediaType = ref<'preview' | 'hd' | 'video'>('preview')
96
+
85
97
  // Computed
86
98
  const hasNextPage = computed(() => !!nextPageToken.value)
87
99
  const hasNoEvents = computed(() => !loading.value && events.value.length === 0 && !error.value)
@@ -382,11 +394,68 @@ function toggleAllEventTypes() {
382
394
  // Open enlarged image view
383
395
  function openEnlargedImage(eventId: string) {
384
396
  enlargedEventId.value = eventId
397
+ currentMediaType.value = 'preview'
398
+ showVideo.value = false
399
+ hdImageUrl.value = null
400
+ hdImageError.value = null
401
+ resetVideo()
385
402
  }
386
403
 
387
404
  // Close enlarged image view
388
405
  function closeEnlargedImage() {
389
406
  enlargedEventId.value = null
407
+ showVideo.value = false
408
+ hdImageUrl.value = null
409
+ hdImageError.value = null
410
+ currentMediaType.value = 'preview'
411
+ resetVideo()
412
+ }
413
+
414
+ // Switch to preview mode
415
+ function showPreview() {
416
+ currentMediaType.value = 'preview'
417
+ showVideo.value = false
418
+ resetVideo()
419
+ }
420
+
421
+ // Load and show HD image
422
+ async function showHdImage() {
423
+ if (!enlargedEvent.value) return
424
+
425
+ currentMediaType.value = 'hd'
426
+ showVideo.value = false
427
+ loadingHdImage.value = true
428
+ hdImageError.value = null
429
+ hdImageUrl.value = null
430
+ resetVideo()
431
+
432
+ const result = await getRecordedImage({
433
+ deviceId: enlargedEvent.value.actorId,
434
+ type: 'main',
435
+ timestamp__gte: enlargedEvent.value.startTimestamp
436
+ })
437
+
438
+ if (result.error) {
439
+ hdImageError.value = result.error.message
440
+ } else if (result.data?.imageData) {
441
+ hdImageUrl.value = result.data.imageData
442
+ } else {
443
+ hdImageError.value = 'No image data returned'
444
+ }
445
+
446
+ loadingHdImage.value = false
447
+ }
448
+
449
+ // Load and show video
450
+ async function showVideoPlayer() {
451
+ if (!enlargedEvent.value) return
452
+
453
+ currentMediaType.value = 'video'
454
+ showVideo.value = true
455
+ hdImageUrl.value = null
456
+ hdImageError.value = null
457
+
458
+ await loadVideo(enlargedEvent.value.actorId, enlargedEvent.value.startTimestamp)
390
459
  }
391
460
 
392
461
  // Handle keyboard events for accessibility
@@ -546,56 +615,147 @@ watch([timeRange, selectedEventTypes], () => {
546
615
 
547
616
  <!-- Enlarged image lightbox -->
548
617
  <div
549
- v-if="enlargedEventId && enlargedImage"
618
+ v-if="enlargedEventId && (enlargedImage || showVideo)"
550
619
  class="lightbox-overlay"
551
620
  @click.self="closeEnlargedImage"
552
621
  data-testid="lightbox-overlay"
553
622
  >
554
623
  <div class="lightbox-content">
555
- <button
556
- class="lightbox-close"
557
- @click="closeEnlargedImage"
558
- aria-label="Close enlarged image"
559
- data-testid="lightbox-close"
560
- >&times;</button>
561
- <div class="lightbox-image-container">
562
- <img :src="enlargedImage" :alt="enlargedEvent?.type || 'Event image'" class="lightbox-image" />
563
- <!-- Bounding box overlay -->
564
- <svg
565
- v-if="enlargedBoundingBoxes.length > 0"
566
- class="bounding-box-overlay"
567
- viewBox="0 0 100 100"
568
- preserveAspectRatio="none"
569
- data-testid="bounding-box-overlay"
570
- >
571
- <rect
572
- v-for="(box, index) in enlargedBoundingBoxes"
573
- :key="index"
574
- :x="box.x * NORMALIZED_TO_PERCENT"
575
- :y="box.y * NORMALIZED_TO_PERCENT"
576
- :width="box.width * NORMALIZED_TO_PERCENT"
577
- :height="box.height * NORMALIZED_TO_PERCENT"
578
- class="bounding-box"
579
- data-testid="bounding-box"
580
- />
581
- </svg>
582
- <!-- Bounding box labels -->
583
- <div
584
- v-for="(box, index) in enlargedBoundingBoxes"
585
- :key="'label-' + index"
586
- class="bounding-box-label"
587
- :style="{
588
- left: (box.x * NORMALIZED_TO_PERCENT) + '%',
589
- top: (box.y * NORMALIZED_TO_PERCENT) + '%'
590
- }"
591
- data-testid="bounding-box-label"
592
- >
593
- {{ box.label || 'Object' }}
594
- <span v-if="box.confidence" class="confidence">
595
- {{ Math.round(box.confidence * 100) }}%
596
- </span>
624
+ <!-- Header with buttons -->
625
+ <div class="lightbox-header">
626
+ <div class="lightbox-buttons">
627
+ <button
628
+ class="media-button"
629
+ :class="{ active: currentMediaType === 'preview' }"
630
+ @click="showPreview"
631
+ >
632
+ Preview
633
+ </button>
634
+ <button
635
+ class="media-button media-button-hd"
636
+ :class="{ active: currentMediaType === 'hd' }"
637
+ @click="showHdImage"
638
+ >
639
+ HD Image
640
+ </button>
641
+ <button
642
+ class="media-button media-button-video"
643
+ :class="{ active: currentMediaType === 'video' }"
644
+ @click="showVideoPlayer"
645
+ >
646
+ Video
647
+ </button>
597
648
  </div>
649
+ <button
650
+ class="lightbox-close"
651
+ @click="closeEnlargedImage"
652
+ aria-label="Close enlarged image"
653
+ data-testid="lightbox-close"
654
+ >&times;</button>
598
655
  </div>
656
+
657
+ <!-- Video mode -->
658
+ <template v-if="showVideo">
659
+ <div v-if="loadingVideo" class="lightbox-loading">Loading video...</div>
660
+ <div v-else-if="videoError" class="lightbox-error">{{ videoError }}</div>
661
+ <video
662
+ v-else-if="videoUrl"
663
+ :ref="(el) => hlsPlayer.videoRef.value = el as HTMLVideoElement | null"
664
+ class="lightbox-video"
665
+ controls
666
+ autoplay
667
+ muted
668
+ playsinline
669
+ />
670
+ </template>
671
+
672
+ <!-- HD Image mode -->
673
+ <template v-else-if="currentMediaType === 'hd'">
674
+ <div v-if="loadingHdImage" class="lightbox-loading">Loading HD image...</div>
675
+ <div v-else-if="hdImageError" class="lightbox-error">{{ hdImageError }}</div>
676
+ <div v-else-if="hdImageUrl" class="lightbox-image-container">
677
+ <img :src="hdImageUrl" :alt="enlargedEvent?.type || 'Event image'" class="lightbox-image" />
678
+ <!-- Bounding box overlay for HD -->
679
+ <svg
680
+ v-if="enlargedBoundingBoxes.length > 0"
681
+ class="bounding-box-overlay"
682
+ viewBox="0 0 100 100"
683
+ preserveAspectRatio="none"
684
+ data-testid="bounding-box-overlay"
685
+ >
686
+ <rect
687
+ v-for="(box, index) in enlargedBoundingBoxes"
688
+ :key="index"
689
+ :x="box.x * NORMALIZED_TO_PERCENT"
690
+ :y="box.y * NORMALIZED_TO_PERCENT"
691
+ :width="box.width * NORMALIZED_TO_PERCENT"
692
+ :height="box.height * NORMALIZED_TO_PERCENT"
693
+ class="bounding-box"
694
+ data-testid="bounding-box"
695
+ />
696
+ </svg>
697
+ <!-- Bounding box labels for HD -->
698
+ <div
699
+ v-for="(box, index) in enlargedBoundingBoxes"
700
+ :key="'label-' + index"
701
+ class="bounding-box-label"
702
+ :style="{
703
+ left: (box.x * NORMALIZED_TO_PERCENT) + '%',
704
+ top: (box.y * NORMALIZED_TO_PERCENT) + '%'
705
+ }"
706
+ data-testid="bounding-box-label"
707
+ >
708
+ {{ box.label || 'Object' }}
709
+ <span v-if="box.confidence" class="confidence">
710
+ {{ Math.round(box.confidence * 100) }}%
711
+ </span>
712
+ </div>
713
+ </div>
714
+ </template>
715
+
716
+ <!-- Preview mode (default) -->
717
+ <template v-else>
718
+ <div v-if="!enlargedImage" class="lightbox-loading">Loading preview...</div>
719
+ <div v-else class="lightbox-image-container">
720
+ <img :src="enlargedImage" :alt="enlargedEvent?.type || 'Event image'" class="lightbox-image" />
721
+ <!-- Bounding box overlay -->
722
+ <svg
723
+ v-if="enlargedBoundingBoxes.length > 0"
724
+ class="bounding-box-overlay"
725
+ viewBox="0 0 100 100"
726
+ preserveAspectRatio="none"
727
+ data-testid="bounding-box-overlay"
728
+ >
729
+ <rect
730
+ v-for="(box, index) in enlargedBoundingBoxes"
731
+ :key="index"
732
+ :x="box.x * NORMALIZED_TO_PERCENT"
733
+ :y="box.y * NORMALIZED_TO_PERCENT"
734
+ :width="box.width * NORMALIZED_TO_PERCENT"
735
+ :height="box.height * NORMALIZED_TO_PERCENT"
736
+ class="bounding-box"
737
+ data-testid="bounding-box"
738
+ />
739
+ </svg>
740
+ <!-- Bounding box labels -->
741
+ <div
742
+ v-for="(box, index) in enlargedBoundingBoxes"
743
+ :key="'label-' + index"
744
+ class="bounding-box-label"
745
+ :style="{
746
+ left: (box.x * NORMALIZED_TO_PERCENT) + '%',
747
+ top: (box.y * NORMALIZED_TO_PERCENT) + '%'
748
+ }"
749
+ data-testid="bounding-box-label"
750
+ >
751
+ {{ box.label || 'Object' }}
752
+ <span v-if="box.confidence" class="confidence">
753
+ {{ Math.round(box.confidence * 100) }}%
754
+ </span>
755
+ </div>
756
+ </div>
757
+ </template>
758
+
599
759
  <div v-if="enlargedEvent" class="lightbox-info">
600
760
  <div class="lightbox-event-line">
601
761
  <span class="lightbox-camera-info">{{ camera.name }} ({{ camera.id }})</span>
@@ -887,10 +1047,50 @@ watch([timeRange, selectedEventTypes], () => {
887
1047
  align-items: center;
888
1048
  }
889
1049
 
1050
+ .lightbox-header {
1051
+ display: flex;
1052
+ justify-content: space-between;
1053
+ align-items: center;
1054
+ width: 100%;
1055
+ margin-bottom: 15px;
1056
+ }
1057
+
1058
+ .lightbox-buttons {
1059
+ display: flex;
1060
+ gap: 10px;
1061
+ }
1062
+
1063
+ .media-button {
1064
+ padding: 8px 16px;
1065
+ background: #42b883;
1066
+ color: white;
1067
+ border: none;
1068
+ border-radius: 4px;
1069
+ cursor: pointer;
1070
+ font-size: 0.9rem;
1071
+ font-weight: 500;
1072
+ opacity: 0.7;
1073
+ transition: opacity 0.2s, background 0.2s;
1074
+ }
1075
+
1076
+ .media-button:hover {
1077
+ opacity: 1;
1078
+ }
1079
+
1080
+ .media-button.active {
1081
+ opacity: 1;
1082
+ box-shadow: 0 0 0 2px white;
1083
+ }
1084
+
1085
+ .media-button-hd {
1086
+ background: #3b82f6;
1087
+ }
1088
+
1089
+ .media-button-video {
1090
+ background: #9b59b6;
1091
+ }
1092
+
890
1093
  .lightbox-close {
891
- position: absolute;
892
- top: -40px;
893
- right: -10px;
894
1094
  background: none;
895
1095
  border: none;
896
1096
  color: white;
@@ -898,13 +1098,35 @@ watch([timeRange, selectedEventTypes], () => {
898
1098
  cursor: pointer;
899
1099
  padding: 5px 10px;
900
1100
  line-height: 1;
901
- z-index: 2001;
902
1101
  }
903
1102
 
904
1103
  .lightbox-close:hover {
905
1104
  color: #ccc;
906
1105
  }
907
1106
 
1107
+ .lightbox-loading,
1108
+ .lightbox-error {
1109
+ color: white;
1110
+ font-size: 1.1rem;
1111
+ padding: 40px;
1112
+ min-height: 200px;
1113
+ display: flex;
1114
+ align-items: center;
1115
+ justify-content: center;
1116
+ }
1117
+
1118
+ .lightbox-error {
1119
+ color: #ff6b6b;
1120
+ }
1121
+
1122
+ .lightbox-video {
1123
+ max-width: 90vw;
1124
+ max-height: 70vh;
1125
+ width: 100%;
1126
+ background: #000;
1127
+ border-radius: 4px;
1128
+ }
1129
+
908
1130
  .lightbox-info {
909
1131
  margin-top: 15px;
910
1132
  text-align: center;
@@ -0,0 +1,272 @@
1
+ import { ref, nextTick, onUnmounted, type Ref } from 'vue'
2
+ import { listMedia, formatTimestamp, useAuthStore } from 'een-api-toolkit'
3
+ import Hls from 'hls.js'
4
+ import { useMediaSessionStore } from '../stores/mediaSession'
5
+
6
+ // Constants
7
+ const SEARCH_WINDOW_MS = 60 * 60 * 1000 // 1 hour before/after target timestamp
8
+ const MAX_MEDIA_PAGE_SIZE = 100 // Limit results for performance
9
+ const MAX_NETWORK_RETRIES = 3 // Maximum retry attempts for network errors
10
+
11
+ // Debug utility - logs only when VITE_DEBUG=true
12
+ const isDebug = import.meta.env?.VITE_DEBUG === 'true'
13
+ function debugError(...args: unknown[]): void {
14
+ if (isDebug) {
15
+ console.error('[useHlsPlayer]', ...args)
16
+ }
17
+ }
18
+
19
+ /** Return type for the useHlsPlayer composable */
20
+ export interface HlsPlayerReturn {
21
+ videoUrl: Ref<string | null>
22
+ videoError: Ref<string | null>
23
+ loadingVideo: Ref<boolean>
24
+ videoRef: Ref<HTMLVideoElement | null>
25
+ loadVideo: (deviceId: string, timestamp: string) => Promise<void>
26
+ resetVideo: () => void
27
+ destroyHls: () => void
28
+ }
29
+
30
+ /**
31
+ * Composable for HLS video playback from EEN recordings.
32
+ * Handles media session initialization, interval search, and HLS.js setup.
33
+ * Uses Pinia store for media session state to ensure consistent behavior
34
+ * across all component instances.
35
+ */
36
+ export function useHlsPlayer(): HlsPlayerReturn {
37
+ const authStore = useAuthStore()
38
+ const mediaSessionStore = useMediaSessionStore()
39
+
40
+ // State
41
+ const videoUrl = ref<string | null>(null)
42
+ const videoError = ref<string | null>(null)
43
+ const loadingVideo = ref(false)
44
+ const videoRef = ref<HTMLVideoElement | null>(null)
45
+
46
+ let hlsInstance: Hls | null = null
47
+ let networkRetryCount = 0
48
+
49
+ /**
50
+ * Initialize media session with caching via Pinia store.
51
+ * Only calls the API once per session, subsequent calls return cached result.
52
+ */
53
+ async function ensureMediaSession(): Promise<boolean> {
54
+ const success = await mediaSessionStore.ensureInitialized()
55
+ if (!success && mediaSessionStore.error) {
56
+ videoError.value = mediaSessionStore.error
57
+ }
58
+ return success
59
+ }
60
+
61
+ /**
62
+ * Destroy the HLS instance and clean up resources.
63
+ */
64
+ function destroyHls() {
65
+ if (hlsInstance) {
66
+ hlsInstance.destroy()
67
+ hlsInstance = null
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Initialize HLS.js with proper authentication and error handling.
73
+ */
74
+ function initHls() {
75
+ if (!videoUrl.value || !videoRef.value) return
76
+
77
+ destroyHls()
78
+
79
+ // Always use hls.js even on Safari - native HLS cannot send Authorization headers
80
+ if (!Hls.isSupported()) {
81
+ videoError.value = 'HLS is not supported in this browser. Please use a modern browser like Chrome, Firefox, or Edge.'
82
+ return
83
+ }
84
+
85
+ // Configure hls.js to send Authorization header for authentication
86
+ hlsInstance = new Hls({
87
+ xhrSetup: function(xhr) {
88
+ xhr.setRequestHeader('Authorization', `Bearer ${authStore.token}`)
89
+ }
90
+ })
91
+
92
+ hlsInstance.loadSource(videoUrl.value)
93
+ hlsInstance.attachMedia(videoRef.value)
94
+
95
+ hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => {
96
+ // Reset retry counter on successful manifest parse
97
+ networkRetryCount = 0
98
+ videoRef.value?.play().catch(() => {
99
+ // Autoplay may be blocked, user can manually play
100
+ })
101
+ })
102
+
103
+ // Enhanced error handling for different error types
104
+ hlsInstance.on(Hls.Events.ERROR, (_, data) => {
105
+ debugError('HLS error:', data)
106
+
107
+ if (data.fatal) {
108
+ switch (data.type) {
109
+ case Hls.ErrorTypes.NETWORK_ERROR:
110
+ // Network error - could be auth issue or connectivity
111
+ if (data.response?.code === 401) {
112
+ videoError.value = 'Authentication expired. Please refresh the page and try again.'
113
+ // Don't retry on auth errors - requires user action
114
+ destroyHls()
115
+ } else if (data.response?.code === 403) {
116
+ videoError.value = 'Access denied to video stream.'
117
+ // Don't retry on permission errors
118
+ destroyHls()
119
+ } else {
120
+ // Retry other network errors with limit
121
+ networkRetryCount++
122
+ if (networkRetryCount <= MAX_NETWORK_RETRIES) {
123
+ videoError.value = `Network error loading video: ${data.details}. Retry ${networkRetryCount}/${MAX_NETWORK_RETRIES}...`
124
+ hlsInstance?.startLoad()
125
+ } else {
126
+ videoError.value = `Network error loading video: ${data.details}. Max retries (${MAX_NETWORK_RETRIES}) exceeded.`
127
+ destroyHls()
128
+ }
129
+ }
130
+ break
131
+
132
+ case Hls.ErrorTypes.MEDIA_ERROR:
133
+ // Media error - try to recover
134
+ videoError.value = `Media error: ${data.details}. Attempting recovery...`
135
+ hlsInstance?.recoverMediaError()
136
+ break
137
+
138
+ default:
139
+ // Other fatal errors
140
+ videoError.value = `HLS error: ${data.type} - ${data.details}`
141
+ destroyHls()
142
+ }
143
+ }
144
+ })
145
+ }
146
+
147
+ /**
148
+ * Load and play HLS video for a given device and timestamp.
149
+ *
150
+ * @param deviceId - The camera device ID
151
+ * @param timestamp - ISO timestamp string (the target time to find video for)
152
+ * @returns Promise that resolves when video is ready or error occurs
153
+ */
154
+ async function loadVideo(deviceId: string, timestamp: string): Promise<void> {
155
+ loadingVideo.value = true
156
+ videoError.value = null
157
+ videoUrl.value = null
158
+ networkRetryCount = 0 // Reset retry counter for new video load
159
+
160
+ // Initialize media session (cached after first call)
161
+ const sessionOk = await ensureMediaSession()
162
+ if (!sessionOk) {
163
+ loadingVideo.value = false
164
+ return
165
+ }
166
+
167
+ // Search for recordings around the target timestamp
168
+ const targetTime = new Date(timestamp)
169
+ const searchStartTime = new Date(targetTime.getTime() - SEARCH_WINDOW_MS)
170
+ const searchEndTime = new Date(targetTime.getTime() + SEARCH_WINDOW_MS)
171
+
172
+ // Use 'main' type for video - HLS is typically only available for main feeds
173
+ const result = await listMedia({
174
+ deviceId,
175
+ type: 'main',
176
+ mediaType: 'video',
177
+ startTimestamp: formatTimestamp(searchStartTime.toISOString()),
178
+ endTimestamp: formatTimestamp(searchEndTime.toISOString()),
179
+ include: ['hlsUrl'],
180
+ pageSize: MAX_MEDIA_PAGE_SIZE
181
+ })
182
+
183
+ if (result.error) {
184
+ videoError.value = result.error.message
185
+ loadingVideo.value = false
186
+ return
187
+ }
188
+
189
+ const intervals = result.data?.results ?? []
190
+
191
+ // Validate target timestamp
192
+ const targetTimeMs = targetTime.getTime()
193
+ if (isNaN(targetTimeMs)) {
194
+ videoError.value = `Invalid timestamp format: ${timestamp}`
195
+ loadingVideo.value = false
196
+ return
197
+ }
198
+
199
+ // Find an interval that contains the target timestamp and has an HLS URL
200
+ const interval = intervals.find(i => {
201
+ if (!i.hlsUrl) return false
202
+ const intervalStart = new Date(i.startTimestamp).getTime()
203
+ const intervalEnd = new Date(i.endTimestamp).getTime()
204
+ // Skip intervals with invalid timestamps
205
+ if (isNaN(intervalStart) || isNaN(intervalEnd)) return false
206
+ return targetTimeMs >= intervalStart && targetTimeMs <= intervalEnd
207
+ })
208
+
209
+ if (!interval?.hlsUrl) {
210
+ // Provide detailed error message
211
+ if (intervals.length === 0) {
212
+ videoError.value = 'No recordings found for this time range'
213
+ } else if (!intervals.some(i => i.hlsUrl)) {
214
+ videoError.value = 'Recordings found but HLS not available'
215
+ } else {
216
+ videoError.value = `No recording contains timestamp ${timestamp}`
217
+ }
218
+ loadingVideo.value = false
219
+ return
220
+ }
221
+
222
+ // Set the HLS URL
223
+ videoUrl.value = interval.hlsUrl
224
+ loadingVideo.value = false
225
+
226
+ // Initialize HLS.js after the DOM has been updated
227
+ await nextTick()
228
+ initHls()
229
+ }
230
+
231
+ /**
232
+ * Reset all video state.
233
+ */
234
+ function resetVideo() {
235
+ destroyHls()
236
+ videoUrl.value = null
237
+ videoError.value = null
238
+ loadingVideo.value = false
239
+ }
240
+
241
+ // Cleanup on unmount
242
+ onUnmounted(() => {
243
+ destroyHls()
244
+ })
245
+
246
+ return {
247
+ // State
248
+ videoUrl,
249
+ videoError,
250
+ loadingVideo,
251
+ videoRef,
252
+
253
+ // Methods
254
+ loadVideo,
255
+ resetVideo,
256
+ destroyHls
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Reset the media session cache.
262
+ * Call this when the user logs out or the session expires to ensure
263
+ * a fresh media session is initialized on next use.
264
+ *
265
+ * @remarks
266
+ * This delegates to the Pinia media session store's reset method.
267
+ * Must be called from within a Vue component context or after Pinia is installed.
268
+ */
269
+ export function resetMediaSessionCache(): void {
270
+ const mediaSessionStore = useMediaSessionStore()
271
+ mediaSessionStore.reset()
272
+ }