een-api-toolkit 0.3.15 → 0.3.20
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/CHANGELOG.md +45 -6
- package/README.md +1 -0
- package/dist/index.cjs +3 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +585 -0
- package/dist/index.js +485 -261
- package/dist/index.js.map +1 -1
- package/docs/AI-CONTEXT.md +144 -1
- package/examples/vue-alerts-metrics/e2e/auth.spec.ts +8 -1
- package/examples/vue-alerts-metrics/package-lock.json +8 -1
- package/examples/vue-alerts-metrics/package.json +4 -3
- package/examples/vue-alerts-metrics/src/components/AlertsList.vue +567 -16
- package/examples/vue-alerts-metrics/src/components/CameraSelector.vue +16 -6
- package/examples/vue-alerts-metrics/src/components/MetricsChart.vue +23 -9
- package/examples/vue-alerts-metrics/src/components/NotificationsList.vue +579 -17
- package/examples/vue-alerts-metrics/src/components/TimeRangeSelector.vue +197 -12
- package/examples/vue-alerts-metrics/src/composables/useHlsPlayer.ts +285 -0
- package/examples/vue-alerts-metrics/src/views/Dashboard.vue +31 -9
- package/examples/vue-alerts-metrics/src/views/Home.vue +56 -7
- package/examples/vue-event-subscriptions/.env.example +15 -0
- package/examples/vue-event-subscriptions/README.md +103 -0
- package/examples/vue-event-subscriptions/e2e/app.spec.ts +71 -0
- package/examples/vue-event-subscriptions/e2e/auth.spec.ts +290 -0
- package/examples/vue-event-subscriptions/index.html +13 -0
- package/examples/vue-event-subscriptions/package-lock.json +1719 -0
- package/examples/vue-event-subscriptions/package.json +28 -0
- package/examples/vue-event-subscriptions/playwright.config.ts +47 -0
- package/examples/vue-event-subscriptions/src/App.vue +233 -0
- package/examples/vue-event-subscriptions/src/main.ts +25 -0
- package/examples/vue-event-subscriptions/src/router/index.ts +68 -0
- package/examples/vue-event-subscriptions/src/views/Callback.vue +76 -0
- package/examples/vue-event-subscriptions/src/views/Home.vue +192 -0
- package/examples/vue-event-subscriptions/src/views/LiveEvents.vue +640 -0
- package/examples/vue-event-subscriptions/src/views/Login.vue +33 -0
- package/examples/vue-event-subscriptions/src/views/Logout.vue +59 -0
- package/examples/vue-event-subscriptions/src/views/Subscriptions.vue +402 -0
- package/examples/vue-event-subscriptions/src/vite-env.d.ts +12 -0
- package/examples/vue-event-subscriptions/tsconfig.json +21 -0
- package/examples/vue-event-subscriptions/tsconfig.node.json +10 -0
- package/examples/vue-event-subscriptions/vite.config.ts +12 -0
- package/package.json +1 -1
|
@@ -1,36 +1,157 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
|
|
2
|
+
import { ref, computed } from 'vue'
|
|
3
|
+
import { formatTimestamp } from 'een-api-toolkit'
|
|
4
|
+
|
|
5
|
+
const props = defineProps<{
|
|
3
6
|
selected: string
|
|
4
7
|
}>()
|
|
5
8
|
|
|
6
9
|
const emit = defineEmits<{
|
|
7
10
|
change: [range: string]
|
|
11
|
+
'update:aggregateMinutes': [minutes: number | undefined]
|
|
8
12
|
}>()
|
|
9
13
|
|
|
10
14
|
const ranges = [
|
|
15
|
+
{ value: 'none', label: 'None' },
|
|
11
16
|
{ value: '1h', label: '1 Hour' },
|
|
12
17
|
{ value: '6h', label: '6 Hours' },
|
|
13
18
|
{ value: '24h', label: '24 Hours' },
|
|
14
19
|
{ value: '7d', label: '7 Days' }
|
|
15
20
|
]
|
|
16
21
|
|
|
22
|
+
const aggregateOptions = [
|
|
23
|
+
{ value: '', label: 'None', minutes: undefined },
|
|
24
|
+
{ value: '60', label: '60 min', minutes: 60 },
|
|
25
|
+
{ value: '360', label: '6h', minutes: 360 },
|
|
26
|
+
{ value: '1440', label: '24h', minutes: 1440 }
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
const selectedAggregate = ref('')
|
|
30
|
+
const showApiFormat = ref(false)
|
|
31
|
+
const startCopied = ref(false)
|
|
32
|
+
const endCopied = ref(false)
|
|
33
|
+
|
|
34
|
+
function getTimeRangeMs(range: string): number {
|
|
35
|
+
switch (range) {
|
|
36
|
+
case '1h': return 60 * 60 * 1000
|
|
37
|
+
case '6h': return 6 * 60 * 60 * 1000
|
|
38
|
+
case '24h': return 24 * 60 * 60 * 1000
|
|
39
|
+
case '7d': return 7 * 24 * 60 * 60 * 1000
|
|
40
|
+
default: return 24 * 60 * 60 * 1000
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const timeRange = computed(() => {
|
|
45
|
+
const now = new Date()
|
|
46
|
+
const rangeMs = getTimeRangeMs(props.selected)
|
|
47
|
+
const startTime = new Date(now.getTime() - rangeMs)
|
|
48
|
+
return { start: startTime, end: now }
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
// Always compute API format timestamps for copying
|
|
52
|
+
const apiStartTimestamp = computed(() => {
|
|
53
|
+
return formatTimestamp(timeRange.value.start.toISOString())
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
const apiEndTimestamp = computed(() => {
|
|
57
|
+
return formatTimestamp(timeRange.value.end.toISOString())
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
const formattedStart = computed(() => {
|
|
61
|
+
if (showApiFormat.value) {
|
|
62
|
+
return apiStartTimestamp.value
|
|
63
|
+
}
|
|
64
|
+
return timeRange.value.start.toLocaleString()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
const formattedEnd = computed(() => {
|
|
68
|
+
if (showApiFormat.value) {
|
|
69
|
+
return apiEndTimestamp.value
|
|
70
|
+
}
|
|
71
|
+
return timeRange.value.end.toLocaleString()
|
|
72
|
+
})
|
|
73
|
+
|
|
17
74
|
function handleClick(range: string) {
|
|
18
75
|
emit('change', range)
|
|
19
76
|
}
|
|
77
|
+
|
|
78
|
+
function toggleFormat() {
|
|
79
|
+
showApiFormat.value = !showApiFormat.value
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function handleAggregateChange() {
|
|
83
|
+
const option = aggregateOptions.find(o => o.value === selectedAggregate.value)
|
|
84
|
+
emit('update:aggregateMinutes', option?.minutes)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function copyStartTimestamp() {
|
|
88
|
+
await navigator.clipboard.writeText(apiStartTimestamp.value)
|
|
89
|
+
startCopied.value = true
|
|
90
|
+
setTimeout(() => {
|
|
91
|
+
startCopied.value = false
|
|
92
|
+
}, 500)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function copyEndTimestamp() {
|
|
96
|
+
await navigator.clipboard.writeText(apiEndTimestamp.value)
|
|
97
|
+
endCopied.value = true
|
|
98
|
+
setTimeout(() => {
|
|
99
|
+
endCopied.value = false
|
|
100
|
+
}, 500)
|
|
101
|
+
}
|
|
20
102
|
</script>
|
|
21
103
|
|
|
22
104
|
<template>
|
|
23
105
|
<div class="time-range-selector">
|
|
24
|
-
<
|
|
25
|
-
|
|
106
|
+
<div class="selector-row">
|
|
107
|
+
<span class="label">Time Range:</span>
|
|
108
|
+
<div class="buttons">
|
|
109
|
+
<button
|
|
110
|
+
v-for="range in ranges"
|
|
111
|
+
:key="range.value"
|
|
112
|
+
:class="{ active: selected === range.value }"
|
|
113
|
+
@click="handleClick(range.value)"
|
|
114
|
+
:data-testid="`time-range-${range.value}`"
|
|
115
|
+
>
|
|
116
|
+
{{ range.label }}
|
|
117
|
+
</button>
|
|
118
|
+
</div>
|
|
119
|
+
<span class="label aggregate-label">Aggregate:</span>
|
|
120
|
+
<div class="buttons">
|
|
121
|
+
<button
|
|
122
|
+
v-for="option in aggregateOptions"
|
|
123
|
+
:key="option.value"
|
|
124
|
+
:class="{ active: selectedAggregate === option.value }"
|
|
125
|
+
@click="selectedAggregate = option.value; handleAggregateChange()"
|
|
126
|
+
:data-testid="`aggregate-${option.value || 'none'}`"
|
|
127
|
+
>
|
|
128
|
+
{{ option.label }}
|
|
129
|
+
</button>
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
<div v-if="selected !== 'none'" class="time-display">
|
|
133
|
+
<div class="timestamps">
|
|
134
|
+
<span class="time-label">From:</span>
|
|
135
|
+
<code
|
|
136
|
+
:class="{ copied: startCopied }"
|
|
137
|
+
@click="copyStartTimestamp"
|
|
138
|
+
data-testid="time-start"
|
|
139
|
+
title="Click to copy API format timestamp"
|
|
140
|
+
>{{ formattedStart }}</code>
|
|
141
|
+
<span class="time-label">To:</span>
|
|
142
|
+
<code
|
|
143
|
+
:class="{ copied: endCopied }"
|
|
144
|
+
@click="copyEndTimestamp"
|
|
145
|
+
data-testid="time-end"
|
|
146
|
+
title="Click to copy API format timestamp"
|
|
147
|
+
>{{ formattedEnd }}</code>
|
|
148
|
+
</div>
|
|
26
149
|
<button
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
@click="handleClick(range.value)"
|
|
31
|
-
:data-testid="`time-range-${range.value}`"
|
|
150
|
+
class="format-toggle"
|
|
151
|
+
@click="toggleFormat"
|
|
152
|
+
data-testid="format-toggle"
|
|
32
153
|
>
|
|
33
|
-
{{
|
|
154
|
+
{{ showApiFormat ? 'Local Time' : 'API Format' }}
|
|
34
155
|
</button>
|
|
35
156
|
</div>
|
|
36
157
|
</div>
|
|
@@ -38,6 +159,12 @@ function handleClick(range: string) {
|
|
|
38
159
|
|
|
39
160
|
<style scoped>
|
|
40
161
|
.time-range-selector {
|
|
162
|
+
display: flex;
|
|
163
|
+
flex-direction: column;
|
|
164
|
+
gap: 8px;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
.selector-row {
|
|
41
168
|
display: flex;
|
|
42
169
|
align-items: center;
|
|
43
170
|
gap: 10px;
|
|
@@ -47,12 +174,16 @@ function handleClick(range: string) {
|
|
|
47
174
|
font-weight: 500;
|
|
48
175
|
}
|
|
49
176
|
|
|
177
|
+
.aggregate-label {
|
|
178
|
+
margin-left: 20px;
|
|
179
|
+
}
|
|
180
|
+
|
|
50
181
|
.buttons {
|
|
51
182
|
display: flex;
|
|
52
183
|
gap: 5px;
|
|
53
184
|
}
|
|
54
185
|
|
|
55
|
-
button {
|
|
186
|
+
.buttons button {
|
|
56
187
|
padding: 8px 16px;
|
|
57
188
|
border: 1px solid #ddd;
|
|
58
189
|
background: #fff;
|
|
@@ -62,13 +193,67 @@ button {
|
|
|
62
193
|
color: #333;
|
|
63
194
|
}
|
|
64
195
|
|
|
65
|
-
button:hover {
|
|
196
|
+
.buttons button:hover {
|
|
66
197
|
background: #f5f5f5;
|
|
67
198
|
}
|
|
68
199
|
|
|
69
|
-
button.active {
|
|
200
|
+
.buttons button.active {
|
|
70
201
|
background: #42b883;
|
|
71
202
|
color: white;
|
|
72
203
|
border-color: #42b883;
|
|
73
204
|
}
|
|
205
|
+
|
|
206
|
+
.time-display {
|
|
207
|
+
display: flex;
|
|
208
|
+
align-items: center;
|
|
209
|
+
gap: 10px;
|
|
210
|
+
flex-wrap: wrap;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
.timestamps {
|
|
214
|
+
display: flex;
|
|
215
|
+
align-items: center;
|
|
216
|
+
gap: 8px;
|
|
217
|
+
font-size: 0.85rem;
|
|
218
|
+
color: #666;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
.time-label {
|
|
222
|
+
font-weight: 500;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
.timestamps code {
|
|
226
|
+
background: #f5f5f5;
|
|
227
|
+
padding: 2px 6px;
|
|
228
|
+
border-radius: 3px;
|
|
229
|
+
font-family: monospace;
|
|
230
|
+
font-size: 0.8rem;
|
|
231
|
+
color: #333;
|
|
232
|
+
cursor: pointer;
|
|
233
|
+
transition: background-color 0.15s ease;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
.timestamps code:hover {
|
|
237
|
+
background: #e0e0e0;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
.timestamps code.copied {
|
|
241
|
+
background: #42b883;
|
|
242
|
+
color: white;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
.format-toggle {
|
|
246
|
+
padding: 4px 10px;
|
|
247
|
+
border: 1px solid #ccc;
|
|
248
|
+
background: #fff;
|
|
249
|
+
border-radius: 4px;
|
|
250
|
+
cursor: pointer;
|
|
251
|
+
font-size: 0.8rem;
|
|
252
|
+
color: #666;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
.format-toggle:hover {
|
|
256
|
+
background: #f0f0f0;
|
|
257
|
+
border-color: #999;
|
|
258
|
+
}
|
|
74
259
|
</style>
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { ref, nextTick, onUnmounted, type Ref } from 'vue'
|
|
2
|
+
import { listMedia, initMediaSession, formatTimestamp, useAuthStore } from 'een-api-toolkit'
|
|
3
|
+
import Hls from 'hls.js'
|
|
4
|
+
|
|
5
|
+
// Constants
|
|
6
|
+
const SEARCH_WINDOW_MS = 60 * 60 * 1000 // 1 hour before/after target timestamp
|
|
7
|
+
const MAX_MEDIA_PAGE_SIZE = 100 // Limit results for performance
|
|
8
|
+
const MAX_NETWORK_RETRIES = 3 // Maximum retry attempts for network errors
|
|
9
|
+
|
|
10
|
+
// Debug utility - logs only when VITE_DEBUG=true
|
|
11
|
+
const isDebug = import.meta.env?.VITE_DEBUG === 'true'
|
|
12
|
+
function debugError(...args: unknown[]): void {
|
|
13
|
+
if (isDebug) {
|
|
14
|
+
console.error('[useHlsPlayer]', ...args)
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Cache media session to avoid redundant initialization calls
|
|
19
|
+
let mediaSessionInitialized = false
|
|
20
|
+
let mediaSessionPromise: Promise<boolean> | null = null
|
|
21
|
+
|
|
22
|
+
/** Return type for the useHlsPlayer composable */
|
|
23
|
+
export interface HlsPlayerReturn {
|
|
24
|
+
videoUrl: Ref<string | null>
|
|
25
|
+
videoError: Ref<string | null>
|
|
26
|
+
loadingVideo: Ref<boolean>
|
|
27
|
+
videoRef: Ref<HTMLVideoElement | null>
|
|
28
|
+
loadVideo: (deviceId: string, timestamp: string) => Promise<void>
|
|
29
|
+
resetVideo: () => void
|
|
30
|
+
destroyHls: () => void
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Composable for HLS video playback from EEN recordings.
|
|
35
|
+
* Handles media session initialization, interval search, and HLS.js setup.
|
|
36
|
+
*/
|
|
37
|
+
export function useHlsPlayer(): HlsPlayerReturn {
|
|
38
|
+
const authStore = useAuthStore()
|
|
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.
|
|
51
|
+
* Only calls the API once per session, subsequent calls return cached result.
|
|
52
|
+
*/
|
|
53
|
+
async function ensureMediaSession(): Promise<boolean> {
|
|
54
|
+
// Return cached result if already initialized
|
|
55
|
+
if (mediaSessionInitialized) {
|
|
56
|
+
return true
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// If initialization is in progress, wait for it
|
|
60
|
+
if (mediaSessionPromise) {
|
|
61
|
+
return mediaSessionPromise
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Start new initialization
|
|
65
|
+
mediaSessionPromise = (async () => {
|
|
66
|
+
const result = await initMediaSession()
|
|
67
|
+
if (result.error) {
|
|
68
|
+
videoError.value = `Media session error: ${result.error.message}`
|
|
69
|
+
mediaSessionPromise = null
|
|
70
|
+
return false
|
|
71
|
+
}
|
|
72
|
+
mediaSessionInitialized = true
|
|
73
|
+
return true
|
|
74
|
+
})()
|
|
75
|
+
|
|
76
|
+
return mediaSessionPromise
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Destroy the HLS instance and clean up resources.
|
|
81
|
+
*/
|
|
82
|
+
function destroyHls() {
|
|
83
|
+
if (hlsInstance) {
|
|
84
|
+
hlsInstance.destroy()
|
|
85
|
+
hlsInstance = null
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Initialize HLS.js with proper authentication and error handling.
|
|
91
|
+
*/
|
|
92
|
+
function initHls() {
|
|
93
|
+
if (!videoUrl.value || !videoRef.value) return
|
|
94
|
+
|
|
95
|
+
destroyHls()
|
|
96
|
+
|
|
97
|
+
// Always use hls.js even on Safari - native HLS cannot send Authorization headers
|
|
98
|
+
if (!Hls.isSupported()) {
|
|
99
|
+
videoError.value = 'HLS is not supported in this browser. Please use a modern browser like Chrome, Firefox, or Edge.'
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Configure hls.js to send Authorization header for authentication
|
|
104
|
+
hlsInstance = new Hls({
|
|
105
|
+
xhrSetup: function(xhr) {
|
|
106
|
+
xhr.setRequestHeader('Authorization', `Bearer ${authStore.token}`)
|
|
107
|
+
}
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
hlsInstance.loadSource(videoUrl.value)
|
|
111
|
+
hlsInstance.attachMedia(videoRef.value)
|
|
112
|
+
|
|
113
|
+
hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => {
|
|
114
|
+
videoRef.value?.play().catch(() => {
|
|
115
|
+
// Autoplay may be blocked, user can manually play
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
// Reset retry counter on successful load
|
|
120
|
+
networkRetryCount = 0
|
|
121
|
+
|
|
122
|
+
// Enhanced error handling for different error types
|
|
123
|
+
hlsInstance.on(Hls.Events.ERROR, (_, data) => {
|
|
124
|
+
debugError('HLS error:', data)
|
|
125
|
+
|
|
126
|
+
if (data.fatal) {
|
|
127
|
+
switch (data.type) {
|
|
128
|
+
case Hls.ErrorTypes.NETWORK_ERROR:
|
|
129
|
+
// Network error - could be auth issue or connectivity
|
|
130
|
+
if (data.response?.code === 401) {
|
|
131
|
+
videoError.value = 'Authentication expired. Please refresh the page and try again.'
|
|
132
|
+
// Don't retry on auth errors - requires user action
|
|
133
|
+
destroyHls()
|
|
134
|
+
} else if (data.response?.code === 403) {
|
|
135
|
+
videoError.value = 'Access denied to video stream.'
|
|
136
|
+
// Don't retry on permission errors
|
|
137
|
+
destroyHls()
|
|
138
|
+
} else {
|
|
139
|
+
// Retry other network errors with limit
|
|
140
|
+
networkRetryCount++
|
|
141
|
+
if (networkRetryCount <= MAX_NETWORK_RETRIES) {
|
|
142
|
+
videoError.value = `Network error loading video: ${data.details}. Retry ${networkRetryCount}/${MAX_NETWORK_RETRIES}...`
|
|
143
|
+
hlsInstance?.startLoad()
|
|
144
|
+
} else {
|
|
145
|
+
videoError.value = `Network error loading video: ${data.details}. Max retries (${MAX_NETWORK_RETRIES}) exceeded.`
|
|
146
|
+
destroyHls()
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
break
|
|
150
|
+
|
|
151
|
+
case Hls.ErrorTypes.MEDIA_ERROR:
|
|
152
|
+
// Media error - try to recover
|
|
153
|
+
videoError.value = `Media error: ${data.details}. Attempting recovery...`
|
|
154
|
+
hlsInstance?.recoverMediaError()
|
|
155
|
+
break
|
|
156
|
+
|
|
157
|
+
default:
|
|
158
|
+
// Other fatal errors
|
|
159
|
+
videoError.value = `HLS error: ${data.type} - ${data.details}`
|
|
160
|
+
destroyHls()
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Load and play HLS video for a given device and timestamp.
|
|
168
|
+
*
|
|
169
|
+
* @param deviceId - The camera device ID
|
|
170
|
+
* @param timestamp - ISO timestamp string (the target time to find video for)
|
|
171
|
+
* @returns Promise that resolves when video is ready or error occurs
|
|
172
|
+
*/
|
|
173
|
+
async function loadVideo(deviceId: string, timestamp: string): Promise<void> {
|
|
174
|
+
loadingVideo.value = true
|
|
175
|
+
videoError.value = null
|
|
176
|
+
videoUrl.value = null
|
|
177
|
+
|
|
178
|
+
// Initialize media session (cached after first call)
|
|
179
|
+
const sessionOk = await ensureMediaSession()
|
|
180
|
+
if (!sessionOk) {
|
|
181
|
+
loadingVideo.value = false
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Search for recordings around the target timestamp
|
|
186
|
+
const targetTime = new Date(timestamp)
|
|
187
|
+
const searchStartTime = new Date(targetTime.getTime() - SEARCH_WINDOW_MS)
|
|
188
|
+
const searchEndTime = new Date(targetTime.getTime() + SEARCH_WINDOW_MS)
|
|
189
|
+
|
|
190
|
+
// Use 'main' type for video - HLS is typically only available for main feeds
|
|
191
|
+
const result = await listMedia({
|
|
192
|
+
deviceId: deviceId,
|
|
193
|
+
type: 'main',
|
|
194
|
+
mediaType: 'video',
|
|
195
|
+
startTimestamp: formatTimestamp(searchStartTime.toISOString()),
|
|
196
|
+
endTimestamp: formatTimestamp(searchEndTime.toISOString()),
|
|
197
|
+
include: ['hlsUrl'],
|
|
198
|
+
pageSize: MAX_MEDIA_PAGE_SIZE
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
if (result.error) {
|
|
202
|
+
videoError.value = result.error.message
|
|
203
|
+
loadingVideo.value = false
|
|
204
|
+
return
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const intervals = result.data?.results ?? []
|
|
208
|
+
|
|
209
|
+
// Validate target timestamp
|
|
210
|
+
const targetTimeMs = targetTime.getTime()
|
|
211
|
+
if (isNaN(targetTimeMs)) {
|
|
212
|
+
videoError.value = `Invalid timestamp format: ${timestamp}`
|
|
213
|
+
loadingVideo.value = false
|
|
214
|
+
return
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Find an interval that contains the target timestamp and has an HLS URL
|
|
218
|
+
const interval = intervals.find(i => {
|
|
219
|
+
if (!i.hlsUrl) return false
|
|
220
|
+
const intervalStart = new Date(i.startTimestamp).getTime()
|
|
221
|
+
const intervalEnd = new Date(i.endTimestamp).getTime()
|
|
222
|
+
// Skip intervals with invalid timestamps
|
|
223
|
+
if (isNaN(intervalStart) || isNaN(intervalEnd)) return false
|
|
224
|
+
return targetTimeMs >= intervalStart && targetTimeMs <= intervalEnd
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
if (!interval?.hlsUrl) {
|
|
228
|
+
// Provide detailed error message
|
|
229
|
+
if (intervals.length === 0) {
|
|
230
|
+
videoError.value = 'No recordings found for this time range'
|
|
231
|
+
} else if (!intervals.some(i => i.hlsUrl)) {
|
|
232
|
+
videoError.value = 'Recordings found but HLS not available'
|
|
233
|
+
} else {
|
|
234
|
+
videoError.value = `No recording contains timestamp ${timestamp}`
|
|
235
|
+
}
|
|
236
|
+
loadingVideo.value = false
|
|
237
|
+
return
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Set the HLS URL
|
|
241
|
+
videoUrl.value = interval.hlsUrl
|
|
242
|
+
loadingVideo.value = false
|
|
243
|
+
|
|
244
|
+
// Initialize HLS.js after the DOM has been updated
|
|
245
|
+
await nextTick()
|
|
246
|
+
initHls()
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Reset all video state.
|
|
251
|
+
*/
|
|
252
|
+
function resetVideo() {
|
|
253
|
+
destroyHls()
|
|
254
|
+
videoUrl.value = null
|
|
255
|
+
videoError.value = null
|
|
256
|
+
loadingVideo.value = false
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Cleanup on unmount
|
|
260
|
+
onUnmounted(() => {
|
|
261
|
+
destroyHls()
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
// State
|
|
266
|
+
videoUrl,
|
|
267
|
+
videoError,
|
|
268
|
+
loadingVideo,
|
|
269
|
+
videoRef,
|
|
270
|
+
|
|
271
|
+
// Methods
|
|
272
|
+
loadVideo,
|
|
273
|
+
resetVideo,
|
|
274
|
+
destroyHls
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Reset the media session cache.
|
|
280
|
+
* Call this when the user logs out or the session expires.
|
|
281
|
+
*/
|
|
282
|
+
export function resetMediaSessionCache() {
|
|
283
|
+
mediaSessionInitialized = false
|
|
284
|
+
mediaSessionPromise = null
|
|
285
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { ref, watch } from 'vue'
|
|
2
|
+
import { ref, watch, computed } from 'vue'
|
|
3
3
|
import type { Camera } from 'een-api-toolkit'
|
|
4
4
|
import CameraSelector from '../components/CameraSelector.vue'
|
|
5
5
|
import TimeRangeSelector from '../components/TimeRangeSelector.vue'
|
|
@@ -8,16 +8,26 @@ import AlertsList from '../components/AlertsList.vue'
|
|
|
8
8
|
import NotificationsList from '../components/NotificationsList.vue'
|
|
9
9
|
|
|
10
10
|
const selectedCamera = ref<Camera | null>(null)
|
|
11
|
-
const selectedTimeRange = ref('
|
|
11
|
+
const selectedTimeRange = ref('none')
|
|
12
|
+
const selectedAggregateMinutes = ref<number | undefined>(undefined)
|
|
13
|
+
const cameraListLoaded = ref(false)
|
|
12
14
|
|
|
13
|
-
|
|
15
|
+
// Track if a specific camera is selected (not "All Cameras")
|
|
16
|
+
const hasSpecificCamera = computed(() => selectedCamera.value !== null)
|
|
17
|
+
|
|
18
|
+
function handleCameraSelect(camera: Camera | null) {
|
|
14
19
|
selectedCamera.value = camera
|
|
20
|
+
cameraListLoaded.value = true
|
|
15
21
|
}
|
|
16
22
|
|
|
17
23
|
function handleTimeRangeChange(range: string) {
|
|
18
24
|
selectedTimeRange.value = range
|
|
19
25
|
}
|
|
20
26
|
|
|
27
|
+
function handleAggregateChange(minutes: number | undefined) {
|
|
28
|
+
selectedAggregateMinutes.value = minutes
|
|
29
|
+
}
|
|
30
|
+
|
|
21
31
|
// Reset lists when camera changes
|
|
22
32
|
watch(selectedCamera, () => {
|
|
23
33
|
// Lists will reset automatically through their props
|
|
@@ -36,20 +46,26 @@ watch(selectedCamera, () => {
|
|
|
36
46
|
<TimeRangeSelector
|
|
37
47
|
:selected="selectedTimeRange"
|
|
38
48
|
@change="handleTimeRangeChange"
|
|
49
|
+
@update:aggregate-minutes="handleAggregateChange"
|
|
39
50
|
data-testid="time-range-selector"
|
|
40
51
|
/>
|
|
41
52
|
</div>
|
|
42
53
|
|
|
43
|
-
<div v-if="!
|
|
44
|
-
<p>
|
|
54
|
+
<div v-if="!cameraListLoaded" class="no-camera-selected">
|
|
55
|
+
<p>Loading cameras...</p>
|
|
45
56
|
</div>
|
|
46
57
|
|
|
47
58
|
<div v-else class="dashboard-content">
|
|
48
59
|
<section class="metrics-section">
|
|
49
60
|
<h3>Event Metrics</h3>
|
|
61
|
+
<div v-if="!hasSpecificCamera" class="select-camera-message">
|
|
62
|
+
<p>Select a specific camera to view event metrics.</p>
|
|
63
|
+
</div>
|
|
50
64
|
<MetricsChart
|
|
51
|
-
|
|
65
|
+
v-else
|
|
66
|
+
:camera="selectedCamera!"
|
|
52
67
|
:time-range="selectedTimeRange"
|
|
68
|
+
:aggregate-minutes="selectedAggregateMinutes"
|
|
53
69
|
/>
|
|
54
70
|
</section>
|
|
55
71
|
|
|
@@ -86,10 +102,9 @@ h2 {
|
|
|
86
102
|
|
|
87
103
|
.controls {
|
|
88
104
|
display: flex;
|
|
89
|
-
|
|
105
|
+
flex-direction: column;
|
|
106
|
+
gap: 15px;
|
|
90
107
|
margin-bottom: 20px;
|
|
91
|
-
align-items: flex-start;
|
|
92
|
-
flex-wrap: wrap;
|
|
93
108
|
}
|
|
94
109
|
|
|
95
110
|
.no-camera-selected {
|
|
@@ -118,6 +133,13 @@ h2 {
|
|
|
118
133
|
color: #333;
|
|
119
134
|
}
|
|
120
135
|
|
|
136
|
+
.select-camera-message {
|
|
137
|
+
text-align: center;
|
|
138
|
+
padding: 40px;
|
|
139
|
+
color: #666;
|
|
140
|
+
font-style: italic;
|
|
141
|
+
}
|
|
142
|
+
|
|
121
143
|
.lists-container {
|
|
122
144
|
display: grid;
|
|
123
145
|
grid-template-columns: 1fr 1fr;
|