exb-camviewer 1.0.0

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/LICENSE.md ADDED
@@ -0,0 +1,7 @@
1
+ Copyright 2026 Lucius Creamer
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,16 @@
1
+ # CamViewer Widget
2
+
3
+ This is a simple widget to view .m3u8 streams using a url field in a feature layer. The widget appears as a button, which when clicked will turn on the "camera" layer, and when a camera is clicked a popup video stream will appear to start streaming the live camera feed.
4
+
5
+ ## Interactive Example
6
+
7
+ This widget can be used and interacted with on my example application, found [here](https://exb.luciuscreamer.com/camera). In case you miss it, the camera widget is the little button at the bottom of the screen. Click it to turn on the camera layer.
8
+ ![A screenshot of the camera application](./cameraExample.png)
9
+
10
+ ## Setting up the widget
11
+
12
+ This widget can be set up by adding the widget to your application, then selecting the map and data source the camera layer is stored in. You will then select the field that the .m3u8 url is stored in, as well as an icon for the main widget button. It is recommended that you set a background color in the style settings, and also set the height and width to "auto".
13
+
14
+ ## Using the widget
15
+
16
+ Click the widget button (Icon selected by builder), and cameras should appear on your map. Click a camera icon on your map, and a viewing window should appear. You can then click the X icon in the top right of the camera window to close the camera feed and unselect the camera feature/features.
Binary file
package/config.json ADDED
@@ -0,0 +1,3 @@
1
+ {
2
+ "icon": "camera"
3
+ }
package/icon.svg ADDED
@@ -0,0 +1,7 @@
1
+ <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
2
+
3
+ <svg fill="#ffffff" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="800px" height="800px" viewBox="0 0 856.506 856.506" xml:space="preserve" stroke="#ffffff">
4
+
5
+
6
+
7
+
package/manifest.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "CamViewer",
3
+ "label": "Camera Viewer",
4
+ "type": "widget",
5
+ "version": "1.18.0",
6
+ "exbVersion": "1.18.0",
7
+ "author": "Lucius Creamer",
8
+ "description": "This widget allows users to view and interact with camera feeds in a map context.",
9
+ "copyright": "",
10
+ "dependency": [
11
+ "jimu-arcgis"
12
+ ],
13
+ "license": "http://www.apache.org/licenses/LICENSE-2.0",
14
+ "properties": {},
15
+ "translatedLocales": [
16
+ "en"
17
+ ],
18
+ "defaultSize": {
19
+ "width": 800,
20
+ "height": 500
21
+ }
22
+ }
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "exb-camviewer",
3
+ "version": "1.0.0",
4
+ "description": "A camera viewer, to view .m3u8 streams via a url in a feature layer.",
5
+ "license": "MIT",
6
+ "author": "Lucius Creamer",
7
+ "homepage": "https://github.com/SunshineLuke90/widgets#readme",
8
+ "bugs": {
9
+ "url": "https://github.com/SunshineLuke90/widgets/issues"
10
+ },
11
+ "dependencies": {
12
+ "hls.js": "^1.6.15"
13
+ },
14
+ "main": "src/runtime/widget.tsx",
15
+ "keywords": [
16
+ "exb-widget",
17
+ "experience-builder",
18
+ "exb"
19
+ ],
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/SunshineLuke90/widgets.git",
23
+ "directory": "CamViewer"
24
+ }
25
+ }
package/src/config.ts ADDED
@@ -0,0 +1,8 @@
1
+ import type { ImmutableObject } from 'seamless-immutable'
2
+ import type { IconResult } from 'jimu-core'
3
+
4
+ export interface Config {
5
+ icon: IconResult;
6
+ }
7
+
8
+ export type IMConfig = ImmutableObject<Config>
@@ -0,0 +1,43 @@
1
+ .widget-camviewer .view-buttons-container {
2
+ display: flex;
3
+ }
4
+
5
+
6
+ .widget-camviewer .jimu-widget{
7
+ display: flex;
8
+ text-align: center;
9
+ color: var(--dark);
10
+ }
11
+
12
+ .widget-camviewer-not-configured {
13
+ display: flex;
14
+ flex-direction: column;
15
+ align-items: center;
16
+ justify-content: center;
17
+ height: 100%;
18
+ width: 100%;
19
+ background-color: var(--light);
20
+ color: var(--dark);
21
+ }
22
+
23
+ .widget-camviewer .view-button {
24
+ text-align: center;
25
+ cursor: pointer;
26
+ /* Default state using theme variables */
27
+ background-color: transparent;
28
+ color: var(--dark);
29
+ align-items: center;
30
+ vertical-align: middle;
31
+ justify-content: center;
32
+ gap: 4px;
33
+ white-space: nowrap;
34
+ overflow: hidden;
35
+ text-overflow: ellipsis;
36
+ size: auto;
37
+ height: auto;
38
+ width: auto;
39
+ button {
40
+ background-color: var(--light);
41
+ color: var(--dark);
42
+ }
43
+ }
@@ -0,0 +1,4 @@
1
+ export default {
2
+ _widgetLabel: 'Camera Viewer',
3
+ notConfigured: 'Please configure the widget in the settings pane.'
4
+ }
@@ -0,0 +1,259 @@
1
+ import { React, type AllWidgetProps, type DataSource, DataSourceComponent, type FeatureLayerQueryParams } from 'jimu-core'
2
+ import type { IMConfig } from '../config'
3
+ import defaultMessages from './translations/default'
4
+ import { JimuMapViewComponent, type JimuMapView, type JimuLayerView } from 'jimu-arcgis'
5
+ import { Button, Icon } from 'jimu-ui'
6
+ import './style.css'
7
+ import { useCallback, useState } from 'react'
8
+ import { createPortal } from 'react-dom'
9
+
10
+ // You need to install hls.js into your client directory
11
+ // Run npm install hls.js in your client directory to install.
12
+ import Hls from 'hls.js'
13
+
14
+ export default function Widget(props: AllWidgetProps<IMConfig>) {
15
+ const { useDataSources, useMapWidgetIds } = props
16
+ const [jimuMapView, setJimuMapView] = React.useState<JimuMapView>(null)
17
+
18
+ const isConfigured = useDataSources && useDataSources.length > 0 && useDataSources[0].dataSourceId && useDataSources[0].fields && useDataSources[0].fields.length > 0
19
+
20
+ // Dedicated state for currentURL and layerView
21
+ const [currentURL, setCurrentURL] = React.useState<string | undefined>(undefined)
22
+ const [layerView, setLayerView] = React.useState<JimuLayerView | undefined>(undefined)
23
+ const [camLayerOn, setCamLayerOn] = React.useState(false)
24
+ const [ds, setDs] = useState<DataSource>(null)
25
+
26
+
27
+ // State for video position and size
28
+ const [videoPos, setVideoPos] = useState({ x: 100, y: 100 })
29
+ const [dragging, setDragging] = useState(false)
30
+ const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 })
31
+ const [resizing, setResizing] = useState(false)
32
+ const [resizeStart, setResizeStart] = useState({ x: 0, y: 0, width: 250, height: 140 })
33
+ const [videoSize, setVideoSize] = useState({ width: 250, height: 140 })
34
+ const [showVideo, setShowVideo] = useState(true)
35
+ const [aspectRatio, setAspectRatio] = useState(16 / 9)
36
+
37
+ const dataRender = (ds: DataSource) => {
38
+ const selectedRecords = ds.getSelectedRecords()
39
+ const fieldanme = props.useDataSources[0].fields[0]
40
+ if (selectedRecords.length > 0) {
41
+ const url = selectedRecords[0].getFieldValue(fieldanme)
42
+ setCurrentURL(url)
43
+ setShowVideo(true)
44
+ }
45
+ return null
46
+ }
47
+
48
+ // Draggable, resizable, closable video portal
49
+ const handleMouseMove = (e) => {
50
+ if (dragging) {
51
+ setVideoPos({ x: e.clientX - dragOffset.x, y: e.clientY - dragOffset.y })
52
+ } else if (resizing) {
53
+ const dx = e.clientX - resizeStart.x
54
+ const newWidth = Math.max(120, resizeStart.width + dx)
55
+ const newHeight = Math.round(newWidth / aspectRatio)
56
+ setVideoSize({ width: newWidth, height: newHeight })
57
+ }
58
+ }
59
+ const handleMouseUp = () => {
60
+ setDragging(false)
61
+ setResizing(false)
62
+ }
63
+ React.useEffect(() => {
64
+ if (dragging || resizing) {
65
+ window.addEventListener('mousemove', handleMouseMove)
66
+ window.addEventListener('mouseup', handleMouseUp)
67
+ return () => {
68
+ window.removeEventListener('mousemove', handleMouseMove)
69
+ window.removeEventListener('mouseup', handleMouseUp)
70
+ }
71
+ }
72
+ })
73
+
74
+ const videoPortal = (currentURL && showVideo) ? createPortal(
75
+ <div
76
+ style={{
77
+ position: 'fixed',
78
+ left: videoPos.x,
79
+ top: videoPos.y,
80
+ zIndex: 9999,
81
+ cursor: dragging ? 'grabbing' : 'grab',
82
+ background: 'rgba(0,0,0,0.2)',
83
+ borderRadius: 10,
84
+ boxShadow: '0 2px 8px rgba(0, 0, 0, 0.2)',
85
+ padding: 4,
86
+ userSelect: 'none',
87
+ minWidth: 120,
88
+ minHeight: 68
89
+ }}
90
+ onMouseDown={e => {
91
+ // Only drag if not clicking close or resize
92
+ if ((e.target as HTMLElement).classList.contains('video-close-btn') || (e.target as HTMLElement).classList.contains('video-resize-handle')) return
93
+ setDragging(true)
94
+ setDragOffset({ x: e.clientX - videoPos.x, y: e.clientY - videoPos.y })
95
+ }}
96
+ >
97
+ {/* Close button */}
98
+ <div
99
+ className="video-close-btn"
100
+ style={{
101
+ position: 'absolute',
102
+ top: 2,
103
+ right: 6,
104
+ fontSize: 18,
105
+ color: '#fff',
106
+ background: 'rgba(0,0,0,0.5)',
107
+ borderRadius: '50%',
108
+ width: 24,
109
+ height: 24,
110
+ display: 'flex',
111
+ alignItems: 'center',
112
+ justifyContent: 'center',
113
+ cursor: 'pointer',
114
+ zIndex: 2
115
+ }}
116
+ onClick={() => {
117
+ ds.clearSelection()
118
+ setShowVideo(false)
119
+ }}
120
+ title="Close"
121
+ >×</div>
122
+ {/* Video */}
123
+ <video
124
+ src={currentURL}
125
+ autoPlay={true}
126
+ style={{ width: videoSize.width, height: videoSize.height, borderRadius: 10, display: 'block' }}
127
+ className={'cameraViewer-video'}
128
+ onLoadedMetadata={e => {
129
+ const video = e.currentTarget
130
+ if (video.videoWidth && video.videoHeight) {
131
+ const ratio = video.videoWidth / video.videoHeight
132
+ setAspectRatio(ratio)
133
+ // Optionally, update window size to match new aspect ratio
134
+ setVideoSize(prev => ({ width: prev.width, height: Math.round(prev.width / ratio) }))
135
+ }
136
+ }}
137
+ />
138
+ {/* Resize handle */}
139
+ <div
140
+ className="video-resize-handle"
141
+ style={{
142
+ position: 'absolute',
143
+ right: 2,
144
+ bottom: 2,
145
+ width: 18,
146
+ height: 18,
147
+ background: 'rgba(0,0,0,0.3)',
148
+ borderRadius: 4,
149
+ cursor: 'nwse-resize',
150
+ zIndex: 2,
151
+ display: 'flex',
152
+ alignItems: 'flex-end',
153
+ justifyContent: 'flex-end',
154
+ }}
155
+ onMouseDown={e => {
156
+ e.stopPropagation()
157
+ setResizing(true)
158
+ setResizeStart({ x: e.clientX, y: e.clientY, width: videoSize.width, height: videoSize.height })
159
+ }}
160
+ title="Resize"
161
+ >
162
+ <svg width="16" height="16" viewBox="0 0 16 16"><polyline points="4,16 16,4" stroke="#fff" strokeWidth="2" fill="none"/></svg>
163
+ </div>
164
+ </div>,
165
+ document.body
166
+ ) : null
167
+
168
+ const activeViewChangeHandler = (jmv: JimuMapView): void => {
169
+ if (jmv) {
170
+ setJimuMapView(jmv)
171
+ }
172
+ }
173
+
174
+ const toggleLayerVisibility = useCallback(() => {
175
+ if (!jimuMapView || !props.useDataSources?.[0] || !layerView) {
176
+ console.warn('Map view, data source, or layerView is not ready.')
177
+ return
178
+ }
179
+ const newVal = !camLayerOn
180
+ setCamLayerOn(newVal)
181
+ layerView.view.set({ visible: newVal })
182
+ console.log('Layer visibility toggled:', newVal)
183
+ }, [jimuMapView, props.useDataSources, layerView, camLayerOn])
184
+
185
+ React.useEffect(() => {
186
+ if (jimuMapView && props.useDataSources?.length > 0 && !layerView) {
187
+ const jimuLayerView = jimuMapView.getJimuLayerViewByDataSourceId(props.useDataSources[0].dataSourceId)
188
+ if (!jimuLayerView) {
189
+ console.warn('Cannot find layer view.')
190
+ return
191
+ }
192
+ setLayerView(jimuLayerView)
193
+ setCamLayerOn(false)
194
+ jimuLayerView.layer.set({ visible: false })
195
+ console.log('Layer visibility turned off (Default):', false)
196
+ }
197
+ }, [jimuMapView, props.useDataSources, layerView])
198
+
199
+ React.useEffect(() => {
200
+ console.log("useEffect called")
201
+ const hls = new Hls({ debug: true })
202
+ console.log("HLS instance created:", hls)
203
+ if (Hls.isSupported() && currentURL) {
204
+ hls.loadSource(currentURL)
205
+ hls.on(Hls.Events.ERROR, (err) => {
206
+ console.log(err)
207
+ })
208
+ } else {
209
+ console.log("load")
210
+ }
211
+ return () => {
212
+ hls.destroy()
213
+ console.log("HLS instance destroyed")
214
+ }
215
+ }, [currentURL])
216
+
217
+ if (!isConfigured) {
218
+ return (
219
+ <div className="widget-camviewer jimu-widget">
220
+ <div className="widget-camviewer-not-configured">
221
+ {defaultMessages.notConfigured}
222
+ </div>
223
+ </div>
224
+ )
225
+ }
226
+
227
+ return (
228
+ <>
229
+ <div className="widget-camviewer jimu-widget" style={{ overflow: 'auto' }}>
230
+ {useMapWidgetIds?.length > 0 && (
231
+ <JimuMapViewComponent
232
+ useMapWidgetId={useMapWidgetIds?.[0]}
233
+ onActiveViewChange={activeViewChangeHandler}
234
+ />
235
+ )}
236
+ <div className="view-button">
237
+ <Button
238
+ aria-label="Button"
239
+ icon
240
+ onClick={toggleLayerVisibility}
241
+ size="default"
242
+ >
243
+ <Icon icon={props.config.icon.svg} size="l" />
244
+ </Button>
245
+ </div>
246
+ <DataSourceComponent useDataSource={props.useDataSources[0]} query={{ where: '1=1' } as FeatureLayerQueryParams} widgetId={props.id}>
247
+ {
248
+ (ds: DataSource) => {
249
+ dataRender(ds)
250
+ setDs(ds)
251
+ return null
252
+ }
253
+ }
254
+ </DataSourceComponent>
255
+ </div>
256
+ {videoPortal}
257
+ </>
258
+ )
259
+ }
@@ -0,0 +1,101 @@
1
+ import {
2
+ Immutable,
3
+ type UseDataSource,
4
+ DataSourceTypes,
5
+ type IMFieldSchema
6
+ } from "jimu-core"
7
+ import type { AllWidgetSettingProps } from "jimu-for-builder"
8
+ import {
9
+ MapWidgetSelector,
10
+ SettingRow,
11
+ SettingSection
12
+ } from "jimu-ui/advanced/setting-components"
13
+ import defaultI18nMessages from "./translations/default"
14
+ import type { IMConfig } from "../config"
15
+ import {
16
+ DataSourceSelector,
17
+ FieldSelector
18
+ } from "jimu-ui/advanced/data-source-selector"
19
+ import { IconPicker } from "jimu-ui/advanced/resource-selector"
20
+
21
+ export default function Setting(props: AllWidgetSettingProps<IMConfig>) {
22
+ const onMapSelected = (useMapWidgetIds: string[]) => {
23
+ props.onSettingChange({
24
+ id: props.id,
25
+ useMapWidgetIds: useMapWidgetIds
26
+ })
27
+ }
28
+
29
+ const onFieldsChange = (fields: IMFieldSchema[]) => {
30
+ const useDataSource = props.useDataSources[0]
31
+ .set(
32
+ "fields",
33
+ fields?.map((f) => f.jimuName)
34
+ )
35
+ .asMutable({ deep: true })
36
+ // Save the selected fields to widget json.
37
+ props.onSettingChange({
38
+ id: props.id,
39
+ useDataSources: [useDataSource]
40
+ })
41
+ }
42
+
43
+ const onDataSourceChange = (useDataSources: UseDataSource[]) => {
44
+ props.onSettingChange({
45
+ id: props.id,
46
+ useDataSources: useDataSources
47
+ })
48
+ }
49
+
50
+ const onIconChange = (icon) => {
51
+ props.onSettingChange({
52
+ id: props.id,
53
+ config: {
54
+ icon: icon
55
+ }
56
+ })
57
+ }
58
+ return (
59
+ <div className="view-layers-toggle-setting">
60
+ <SettingSection
61
+ title={props.intl.formatMessage({
62
+ id: "selectedMapLabel",
63
+ defaultMessage: defaultI18nMessages.selectedMap
64
+ })}
65
+ >
66
+ <SettingRow>
67
+ <IconPicker
68
+ icon={props.config?.icon as any}
69
+ onChange={onIconChange}
70
+ />
71
+ </SettingRow>
72
+ <SettingRow>
73
+ <MapWidgetSelector
74
+ onSelect={onMapSelected}
75
+ useMapWidgetIds={props.useMapWidgetIds}
76
+ />
77
+ </SettingRow>
78
+ <SettingRow>
79
+ <DataSourceSelector
80
+ types={Immutable([DataSourceTypes.FeatureLayer])}
81
+ mustUseDataSource={true}
82
+ useDataSources={props.useDataSources}
83
+ useDataSourcesEnabled={props.useDataSourcesEnabled}
84
+ onChange={onDataSourceChange}
85
+ widgetId={props.id}
86
+ />
87
+ </SettingRow>
88
+ <SettingRow>
89
+ <FieldSelector
90
+ useDataSources={props.useDataSources}
91
+ useDropdown={true}
92
+ isMultiple={false}
93
+ isDataSourceDropDownHidden={true}
94
+ onChange={onFieldsChange}
95
+ selectedFields={props.useDataSources?.[0].fields}
96
+ />
97
+ </SettingRow>
98
+ </SettingSection>
99
+ </div>
100
+ )
101
+ }
@@ -0,0 +1,4 @@
1
+ export default {
2
+ selectedMap: 'Choose Data Source',
3
+ layers: 'Layers'
4
+ }