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.
- 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 +151 -10
- package/README.md +1 -0
- package/dist/index.cjs +3 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +561 -0
- package/dist/index.js +483 -260
- package/dist/index.js.map +1 -1
- package/docs/AI-CONTEXT.md +128 -1648
- 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/.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 +1726 -0
- package/examples/vue-event-subscriptions/package.json +29 -0
- package/examples/vue-event-subscriptions/playwright.config.ts +47 -0
- package/examples/vue-event-subscriptions/src/App.vue +193 -0
- package/examples/vue-event-subscriptions/src/composables/useHlsPlayer.ts +272 -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/stores/connection.ts +101 -0
- package/examples/vue-event-subscriptions/src/stores/mediaSession.ts +79 -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 +901 -0
- package/examples/vue-event-subscriptions/src/views/Login.vue +33 -0
- package/examples/vue-event-subscriptions/src/views/Logout.vue +65 -0
- package/examples/vue-event-subscriptions/src/views/Subscriptions.vue +389 -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/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
|
@@ -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
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
:
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
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
|
+
>×</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
|
+
}
|