nwb-video-widgets 0.1.0__py3-none-any.whl
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.
- nwb_video_widgets/__init__.py +16 -0
- nwb_video_widgets/_utils.py +328 -0
- nwb_video_widgets/dandi_pose_widget.py +356 -0
- nwb_video_widgets/dandi_video_widget.py +155 -0
- nwb_video_widgets/local_pose_widget.py +334 -0
- nwb_video_widgets/local_video_widget.py +130 -0
- nwb_video_widgets/pose_widget.css +624 -0
- nwb_video_widgets/pose_widget.js +798 -0
- nwb_video_widgets/video_widget.css +484 -0
- nwb_video_widgets/video_widget.js +566 -0
- nwb_video_widgets/video_widget.py +170 -0
- nwb_video_widgets-0.1.0.dist-info/METADATA +174 -0
- nwb_video_widgets-0.1.0.dist-info/RECORD +15 -0
- nwb_video_widgets-0.1.0.dist-info/WHEEL +4 -0
- nwb_video_widgets-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-camera video player widget for synchronized playback.
|
|
3
|
+
* Displays videos in a configurable grid layout with unified controls.
|
|
4
|
+
*
|
|
5
|
+
* @typedef {Object.<string, string>} VideoUrls - Mapping of video names to URLs
|
|
6
|
+
* @typedef {string[][]} GridLayout - 2D array defining video grid (rows x cols)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Format seconds as MM:SS.ms string for session time display.
|
|
11
|
+
* @param {number} seconds
|
|
12
|
+
* @returns {string}
|
|
13
|
+
*/
|
|
14
|
+
function formatTime(seconds) {
|
|
15
|
+
const mins = Math.floor(seconds / 60);
|
|
16
|
+
const secs = Math.floor(seconds % 60);
|
|
17
|
+
const ms = Math.floor((seconds % 1) * 10);
|
|
18
|
+
return mins + ":" + secs.toString().padStart(2, "0") + "." + ms;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Create an SVG icon element.
|
|
23
|
+
* @param {"play" | "pause" | "settings" | "warning"} type - Icon type
|
|
24
|
+
* @returns {SVGElement}
|
|
25
|
+
*/
|
|
26
|
+
function createIcon(type) {
|
|
27
|
+
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
28
|
+
svg.setAttribute("width", "16");
|
|
29
|
+
svg.setAttribute("height", "16");
|
|
30
|
+
svg.setAttribute("viewBox", "0 0 24 24");
|
|
31
|
+
svg.setAttribute("fill", "currentColor");
|
|
32
|
+
|
|
33
|
+
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
|
34
|
+
if (type === "play") {
|
|
35
|
+
path.setAttribute("d", "M8 5v14l11-7z");
|
|
36
|
+
} else if (type === "pause") {
|
|
37
|
+
path.setAttribute("d", "M6 19h4V5H6v14zm8-14v14h4V5h-4z");
|
|
38
|
+
} else if (type === "settings") {
|
|
39
|
+
path.setAttribute(
|
|
40
|
+
"d",
|
|
41
|
+
"M19.14 12.94c.04-.31.06-.63.06-.94 0-.31-.02-.63-.06-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.04.31-.06.63-.06.94s.02.63.06.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"
|
|
42
|
+
);
|
|
43
|
+
} else if (type === "warning") {
|
|
44
|
+
path.setAttribute(
|
|
45
|
+
"d",
|
|
46
|
+
"M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z"
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
svg.appendChild(path);
|
|
50
|
+
return svg;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Check if two videos are compatible for synchronized playback.
|
|
55
|
+
* Videos are compatible if their start times are within tolerance and they have overlapping time ranges.
|
|
56
|
+
* @param {Object} infoA - Video info {start, end, frames}
|
|
57
|
+
* @param {Object} infoB - Video info {start, end, frames}
|
|
58
|
+
* @param {number} tolerance - Maximum allowed difference in start times (default 1.0 second)
|
|
59
|
+
* @returns {boolean}
|
|
60
|
+
*/
|
|
61
|
+
function areCompatible(infoA, infoB, tolerance = 1.0) {
|
|
62
|
+
const startDiff = Math.abs(infoA.start - infoB.start);
|
|
63
|
+
if (startDiff > tolerance) {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
// Check if there's overlap
|
|
67
|
+
const overlapStart = Math.max(infoA.start, infoB.start);
|
|
68
|
+
const overlapEnd = Math.min(infoA.end, infoB.end);
|
|
69
|
+
return overlapEnd > overlapStart;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Calculate grid dimensions based on layout mode and number of videos.
|
|
74
|
+
* @param {string} layoutMode - "row", "column", or "grid"
|
|
75
|
+
* @param {number} count - Number of videos
|
|
76
|
+
* @returns {{rows: number, cols: number}}
|
|
77
|
+
*/
|
|
78
|
+
function calculateGridDimensions(layoutMode, count) {
|
|
79
|
+
if (count === 0) return { rows: 0, cols: 0 };
|
|
80
|
+
|
|
81
|
+
if (layoutMode === "row") {
|
|
82
|
+
return { rows: 1, cols: count };
|
|
83
|
+
} else if (layoutMode === "column") {
|
|
84
|
+
return { rows: count, cols: 1 };
|
|
85
|
+
} else {
|
|
86
|
+
// Grid mode: square-ish layout
|
|
87
|
+
const cols = Math.ceil(Math.sqrt(count));
|
|
88
|
+
const rows = Math.ceil(count / cols);
|
|
89
|
+
return { rows, cols };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Render the multi-video player widget.
|
|
95
|
+
*
|
|
96
|
+
* This is the entry point called by anywidget when the widget is displayed.
|
|
97
|
+
*
|
|
98
|
+
* @param {Object} context - Provided by anywidget
|
|
99
|
+
* @param {Object} context.model - Proxy to Python traitlets. Use model.get('name')
|
|
100
|
+
* to read synced traits and model.set('name', value) + model.save_changes()
|
|
101
|
+
* to update them. Listen for changes with model.on('change:name', callback).
|
|
102
|
+
* @param {HTMLElement} context.el - The DOM element where the widget should render.
|
|
103
|
+
* Append all UI elements to this container.
|
|
104
|
+
*/
|
|
105
|
+
function render({ model, el }) {
|
|
106
|
+
// Root wrapper with scoped class
|
|
107
|
+
const wrapper = document.createElement("div");
|
|
108
|
+
wrapper.classList.add("video-widget");
|
|
109
|
+
|
|
110
|
+
// Control bar
|
|
111
|
+
const controls = document.createElement("div");
|
|
112
|
+
controls.classList.add("video-widget__controls");
|
|
113
|
+
|
|
114
|
+
const playPauseBtn = document.createElement("button");
|
|
115
|
+
playPauseBtn.classList.add("video-widget__button");
|
|
116
|
+
playPauseBtn.appendChild(createIcon("play"));
|
|
117
|
+
|
|
118
|
+
const settingsBtn = document.createElement("button");
|
|
119
|
+
settingsBtn.classList.add("video-widget__button", "video-widget__settings-btn");
|
|
120
|
+
settingsBtn.appendChild(createIcon("settings"));
|
|
121
|
+
settingsBtn.title = "Video Settings";
|
|
122
|
+
|
|
123
|
+
const seekBar = document.createElement("input");
|
|
124
|
+
seekBar.type = "range";
|
|
125
|
+
seekBar.min = 0;
|
|
126
|
+
seekBar.max = 100;
|
|
127
|
+
seekBar.value = 0;
|
|
128
|
+
seekBar.classList.add("video-widget__seekbar");
|
|
129
|
+
|
|
130
|
+
const timeLabel = document.createElement("span");
|
|
131
|
+
timeLabel.textContent = "0:00.0 / 0:00.0";
|
|
132
|
+
timeLabel.classList.add("video-widget__time-label");
|
|
133
|
+
|
|
134
|
+
controls.appendChild(playPauseBtn);
|
|
135
|
+
controls.appendChild(settingsBtn);
|
|
136
|
+
controls.appendChild(seekBar);
|
|
137
|
+
controls.appendChild(timeLabel);
|
|
138
|
+
|
|
139
|
+
// Settings panel (collapsible)
|
|
140
|
+
const settingsPanel = document.createElement("div");
|
|
141
|
+
settingsPanel.classList.add("video-widget__settings-panel");
|
|
142
|
+
|
|
143
|
+
const settingsPanelHeader = document.createElement("div");
|
|
144
|
+
settingsPanelHeader.classList.add("video-widget__settings-header");
|
|
145
|
+
|
|
146
|
+
const settingsTitle = document.createElement("span");
|
|
147
|
+
settingsTitle.classList.add("video-widget__settings-title");
|
|
148
|
+
settingsTitle.textContent = "Settings";
|
|
149
|
+
|
|
150
|
+
const closeBtn = document.createElement("button");
|
|
151
|
+
closeBtn.classList.add("video-widget__close-btn");
|
|
152
|
+
closeBtn.textContent = "Close";
|
|
153
|
+
closeBtn.addEventListener("click", () => {
|
|
154
|
+
model.set("settings_open", false);
|
|
155
|
+
model.save_changes();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
settingsPanelHeader.appendChild(settingsTitle);
|
|
159
|
+
settingsPanelHeader.appendChild(closeBtn);
|
|
160
|
+
settingsPanel.appendChild(settingsPanelHeader);
|
|
161
|
+
|
|
162
|
+
const videoSelectionSection = document.createElement("div");
|
|
163
|
+
videoSelectionSection.classList.add("video-widget__video-selection-section");
|
|
164
|
+
|
|
165
|
+
const videoSelectionTitle = document.createElement("span");
|
|
166
|
+
videoSelectionTitle.classList.add("video-widget__section-title");
|
|
167
|
+
videoSelectionTitle.textContent = "Video Selection";
|
|
168
|
+
videoSelectionSection.appendChild(videoSelectionTitle);
|
|
169
|
+
|
|
170
|
+
const videoSelectionHint = document.createElement("p");
|
|
171
|
+
videoSelectionHint.classList.add("video-widget__section-hint");
|
|
172
|
+
videoSelectionHint.textContent = "Videos are displayed in selection order and sync to the first selected.";
|
|
173
|
+
videoSelectionSection.appendChild(videoSelectionHint);
|
|
174
|
+
|
|
175
|
+
const videoList = document.createElement("div");
|
|
176
|
+
videoList.classList.add("video-widget__video-list");
|
|
177
|
+
videoSelectionSection.appendChild(videoList);
|
|
178
|
+
|
|
179
|
+
settingsPanel.appendChild(videoSelectionSection);
|
|
180
|
+
|
|
181
|
+
const layoutSection = document.createElement("div");
|
|
182
|
+
layoutSection.classList.add("video-widget__layout-section");
|
|
183
|
+
|
|
184
|
+
const layoutTitle = document.createElement("span");
|
|
185
|
+
layoutTitle.classList.add("video-widget__section-title");
|
|
186
|
+
layoutTitle.textContent = "Video Grid Layout";
|
|
187
|
+
layoutSection.appendChild(layoutTitle);
|
|
188
|
+
|
|
189
|
+
const layoutOptionsContainer = document.createElement("div");
|
|
190
|
+
layoutOptionsContainer.classList.add("video-widget__layout-options");
|
|
191
|
+
|
|
192
|
+
const layoutModes = ["row", "column", "grid"];
|
|
193
|
+
layoutModes.forEach((option) => {
|
|
194
|
+
const radioContainer = document.createElement("label");
|
|
195
|
+
radioContainer.classList.add("video-widget__layout-option");
|
|
196
|
+
|
|
197
|
+
const radio = document.createElement("input");
|
|
198
|
+
radio.type = "radio";
|
|
199
|
+
radio.name = "layout-mode";
|
|
200
|
+
radio.value = option;
|
|
201
|
+
radio.checked = option === model.get("layout_mode");
|
|
202
|
+
radio.addEventListener("change", () => {
|
|
203
|
+
if (radio.checked) {
|
|
204
|
+
model.set("layout_mode", option);
|
|
205
|
+
model.save_changes();
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
const labelText = document.createElement("span");
|
|
210
|
+
labelText.textContent = option.charAt(0).toUpperCase() + option.slice(1);
|
|
211
|
+
|
|
212
|
+
radioContainer.appendChild(radio);
|
|
213
|
+
radioContainer.appendChild(labelText);
|
|
214
|
+
layoutOptionsContainer.appendChild(radioContainer);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
layoutSection.appendChild(layoutOptionsContainer);
|
|
218
|
+
settingsPanel.appendChild(layoutSection);
|
|
219
|
+
|
|
220
|
+
// Grid container - using CSS Grid for proper 2D layout
|
|
221
|
+
const gridContainer = document.createElement("div");
|
|
222
|
+
gridContainer.classList.add("video-widget__grid");
|
|
223
|
+
|
|
224
|
+
/** @type {HTMLVideoElement[]} */
|
|
225
|
+
let videos = [];
|
|
226
|
+
/** @type {HTMLDivElement[]} */
|
|
227
|
+
let videoContainers = [];
|
|
228
|
+
let isPlaying = false;
|
|
229
|
+
let syncAnimationId = null;
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Update the settings panel with available videos and their compatibility status.
|
|
233
|
+
*/
|
|
234
|
+
function updateSettingsPanel() {
|
|
235
|
+
const availableVideos = model.get("available_videos");
|
|
236
|
+
const selectedVideos = model.get("selected_videos") || [];
|
|
237
|
+
const layoutMode = model.get("layout_mode") || "row";
|
|
238
|
+
const settingsOpen = model.get("settings_open");
|
|
239
|
+
|
|
240
|
+
// Toggle panel visibility
|
|
241
|
+
if (settingsOpen) {
|
|
242
|
+
settingsPanel.classList.add("video-widget__settings-panel--open");
|
|
243
|
+
} else {
|
|
244
|
+
settingsPanel.classList.remove("video-widget__settings-panel--open");
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Update layout radio buttons
|
|
248
|
+
const radios = layoutSection.querySelectorAll('input[type="radio"]');
|
|
249
|
+
radios.forEach((radio) => {
|
|
250
|
+
radio.checked = radio.value === layoutMode;
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// Clear and rebuild video list
|
|
254
|
+
videoList.innerHTML = "";
|
|
255
|
+
|
|
256
|
+
const videoNames = Object.keys(availableVideos);
|
|
257
|
+
if (videoNames.length === 0) {
|
|
258
|
+
const emptyMsg = document.createElement("p");
|
|
259
|
+
emptyMsg.classList.add("video-widget__empty-msg");
|
|
260
|
+
emptyMsg.textContent = "No videos available.";
|
|
261
|
+
videoList.appendChild(emptyMsg);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
videoNames.forEach((name) => {
|
|
266
|
+
const info = availableVideos[name];
|
|
267
|
+
const isSelected = selectedVideos.includes(name);
|
|
268
|
+
|
|
269
|
+
// Check compatibility with currently selected videos
|
|
270
|
+
let isCompatible = true;
|
|
271
|
+
if (!isSelected && selectedVideos.length > 0) {
|
|
272
|
+
isCompatible = selectedVideos.every((selectedName) => {
|
|
273
|
+
const selectedInfo = availableVideos[selectedName];
|
|
274
|
+
return areCompatible(info, selectedInfo);
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const videoItem = document.createElement("div");
|
|
279
|
+
videoItem.classList.add("video-widget__video-item");
|
|
280
|
+
if (!isCompatible) {
|
|
281
|
+
videoItem.classList.add("video-widget__video-item--incompatible");
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const checkbox = document.createElement("input");
|
|
285
|
+
checkbox.type = "checkbox";
|
|
286
|
+
checkbox.id = "video-" + name;
|
|
287
|
+
checkbox.checked = isSelected;
|
|
288
|
+
checkbox.disabled = !isCompatible && !isSelected;
|
|
289
|
+
checkbox.addEventListener("change", () => {
|
|
290
|
+
const currentSelected = [...(model.get("selected_videos") || [])];
|
|
291
|
+
if (checkbox.checked) {
|
|
292
|
+
if (!currentSelected.includes(name)) {
|
|
293
|
+
currentSelected.push(name);
|
|
294
|
+
}
|
|
295
|
+
} else {
|
|
296
|
+
const index = currentSelected.indexOf(name);
|
|
297
|
+
if (index > -1) {
|
|
298
|
+
currentSelected.splice(index, 1);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
model.set("selected_videos", currentSelected);
|
|
302
|
+
model.save_changes();
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
const label = document.createElement("label");
|
|
306
|
+
label.htmlFor = "video-" + name;
|
|
307
|
+
label.classList.add("video-widget__video-item-label");
|
|
308
|
+
label.textContent = name;
|
|
309
|
+
|
|
310
|
+
const timeRange = document.createElement("span");
|
|
311
|
+
timeRange.classList.add("video-widget__video-item-time");
|
|
312
|
+
timeRange.textContent = formatTime(info.start) + " - " + formatTime(info.end);
|
|
313
|
+
|
|
314
|
+
videoItem.appendChild(checkbox);
|
|
315
|
+
videoItem.appendChild(label);
|
|
316
|
+
videoItem.appendChild(timeRange);
|
|
317
|
+
|
|
318
|
+
if (!isCompatible) {
|
|
319
|
+
const warningIcon = document.createElement("span");
|
|
320
|
+
warningIcon.classList.add("video-widget__warning-icon");
|
|
321
|
+
warningIcon.title = "Incompatible time range with selected videos";
|
|
322
|
+
warningIcon.appendChild(createIcon("warning"));
|
|
323
|
+
videoItem.appendChild(warningIcon);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
videoList.appendChild(videoItem);
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Synchronize all videos to the master (first) video.
|
|
332
|
+
* Corrects drift that occurs due to network latency and buffering differences.
|
|
333
|
+
*/
|
|
334
|
+
function syncVideos() {
|
|
335
|
+
if (videos.length < 2 || !isPlaying) {
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const masterTime = videos[0].currentTime;
|
|
340
|
+
for (let i = 1; i < videos.length; i++) {
|
|
341
|
+
const drift = videos[i].currentTime - masterTime;
|
|
342
|
+
// Correct if drift exceeds 100ms
|
|
343
|
+
if (Math.abs(drift) > 0.1) {
|
|
344
|
+
videos[i].currentTime = masterTime;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
syncAnimationId = requestAnimationFrame(syncVideos);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Update play/pause button content.
|
|
353
|
+
* @param {boolean} playing - Current play state
|
|
354
|
+
*/
|
|
355
|
+
function updatePlayPauseButton(playing) {
|
|
356
|
+
playPauseBtn.innerHTML = "";
|
|
357
|
+
playPauseBtn.appendChild(createIcon(playing ? "pause" : "play"));
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Get the session time offset for the first selected video.
|
|
362
|
+
* This is the starting timestamp from the NWB file.
|
|
363
|
+
*/
|
|
364
|
+
function getSessionTimeOffset() {
|
|
365
|
+
const timestamps = model.get("video_timestamps");
|
|
366
|
+
const selectedVideos = model.get("selected_videos") || [];
|
|
367
|
+
// Find the first selected video that has timestamps
|
|
368
|
+
for (const name of selectedVideos) {
|
|
369
|
+
if (timestamps[name] && timestamps[name].length > 0) {
|
|
370
|
+
return timestamps[name][0];
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return 0;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Get the session end time (last timestamp) for the first selected video.
|
|
378
|
+
*/
|
|
379
|
+
function getSessionEndTime() {
|
|
380
|
+
const timestamps = model.get("video_timestamps");
|
|
381
|
+
const selectedVideos = model.get("selected_videos") || [];
|
|
382
|
+
for (const name of selectedVideos) {
|
|
383
|
+
if (timestamps[name] && timestamps[name].length > 1) {
|
|
384
|
+
return timestamps[name][timestamps[name].length - 1];
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return null; // Will fall back to video duration
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function updateVideos() {
|
|
391
|
+
gridContainer.innerHTML = "";
|
|
392
|
+
videos = [];
|
|
393
|
+
videoContainers = [];
|
|
394
|
+
const urls = model.get("video_urls");
|
|
395
|
+
const selectedVideos = model.get("selected_videos") || [];
|
|
396
|
+
const layoutMode = model.get("layout_mode") || "row";
|
|
397
|
+
|
|
398
|
+
// Filter to only selected videos that have URLs
|
|
399
|
+
const videosToShow = selectedVideos.filter((name) => urls[name]);
|
|
400
|
+
|
|
401
|
+
if (videosToShow.length === 0) {
|
|
402
|
+
const emptyMsg = document.createElement("div");
|
|
403
|
+
emptyMsg.classList.add("video-widget__empty-grid-msg");
|
|
404
|
+
emptyMsg.textContent = "Select videos above to display them here.";
|
|
405
|
+
gridContainer.appendChild(emptyMsg);
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Calculate grid dimensions based on layout mode
|
|
410
|
+
const { rows: numRows, cols: numCols } = calculateGridDimensions(
|
|
411
|
+
layoutMode,
|
|
412
|
+
videosToShow.length
|
|
413
|
+
);
|
|
414
|
+
|
|
415
|
+
gridContainer.style.gridTemplateColumns = "repeat(" + numCols + ", auto)";
|
|
416
|
+
gridContainer.style.gridTemplateRows = "repeat(" + numRows + ", auto)";
|
|
417
|
+
|
|
418
|
+
// Place videos in grid cells based on layout mode
|
|
419
|
+
videosToShow.forEach((name, index) => {
|
|
420
|
+
const url = urls[name];
|
|
421
|
+
const rowIdx = Math.floor(index / numCols);
|
|
422
|
+
const colIdx = index % numCols;
|
|
423
|
+
|
|
424
|
+
const videoCell = document.createElement("div");
|
|
425
|
+
videoCell.classList.add("video-widget__video-cell");
|
|
426
|
+
videoCell.style.gridRow = rowIdx + 1;
|
|
427
|
+
videoCell.style.gridColumn = colIdx + 1;
|
|
428
|
+
|
|
429
|
+
const videoContainer = document.createElement("div");
|
|
430
|
+
videoContainer.classList.add("video-widget__video-container");
|
|
431
|
+
videoContainers.push(videoContainer);
|
|
432
|
+
|
|
433
|
+
const video = document.createElement("video");
|
|
434
|
+
video.classList.add("video-widget__video");
|
|
435
|
+
video.src = url;
|
|
436
|
+
video.muted = true; // Mute to allow autoplay
|
|
437
|
+
video.preload = "auto"; // Preload video data
|
|
438
|
+
videos.push(video);
|
|
439
|
+
|
|
440
|
+
// Loading spinner
|
|
441
|
+
const loadingDiv = document.createElement("div");
|
|
442
|
+
loadingDiv.classList.add("video-widget__loading");
|
|
443
|
+
const spinner = document.createElement("div");
|
|
444
|
+
spinner.classList.add("video-widget__spinner");
|
|
445
|
+
loadingDiv.appendChild(spinner);
|
|
446
|
+
|
|
447
|
+
// Video loading events
|
|
448
|
+
video.addEventListener("loadstart", () => {
|
|
449
|
+
videoContainer.classList.add("video-widget__video-container--loading");
|
|
450
|
+
});
|
|
451
|
+
video.addEventListener("canplay", () => {
|
|
452
|
+
videoContainer.classList.remove(
|
|
453
|
+
"video-widget__video-container--loading"
|
|
454
|
+
);
|
|
455
|
+
});
|
|
456
|
+
video.addEventListener("error", () => {
|
|
457
|
+
console.error("Video error for " + name + ":", video.error);
|
|
458
|
+
videoContainer.classList.remove(
|
|
459
|
+
"video-widget__video-container--loading"
|
|
460
|
+
);
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
videoContainer.appendChild(video);
|
|
464
|
+
videoContainer.appendChild(loadingDiv);
|
|
465
|
+
|
|
466
|
+
const label = document.createElement("p");
|
|
467
|
+
label.textContent = name.replace("Video", "").replace("Camera", "");
|
|
468
|
+
label.classList.add("video-widget__video-label");
|
|
469
|
+
|
|
470
|
+
videoCell.appendChild(videoContainer);
|
|
471
|
+
videoCell.appendChild(label);
|
|
472
|
+
gridContainer.appendChild(videoCell);
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
// Update seek bar max when metadata loads
|
|
476
|
+
if (videos.length > 0) {
|
|
477
|
+
videos[0].addEventListener("loadedmetadata", () => {
|
|
478
|
+
seekBar.max = videos[0].duration;
|
|
479
|
+
const offset = getSessionTimeOffset();
|
|
480
|
+
const endTime = getSessionEndTime();
|
|
481
|
+
const displayEnd = endTime !== null ? endTime : offset + videos[0].duration;
|
|
482
|
+
timeLabel.textContent = formatTime(offset) + " / " + formatTime(displayEnd);
|
|
483
|
+
});
|
|
484
|
+
videos[0].addEventListener("timeupdate", () => {
|
|
485
|
+
if (!seekBar.matches(":active")) {
|
|
486
|
+
seekBar.value = videos[0].currentTime;
|
|
487
|
+
}
|
|
488
|
+
const offset = getSessionTimeOffset();
|
|
489
|
+
const endTime = getSessionEndTime();
|
|
490
|
+
const displayEnd = endTime !== null ? endTime : offset + videos[0].duration;
|
|
491
|
+
const currentSessionTime = offset + videos[0].currentTime;
|
|
492
|
+
timeLabel.textContent =
|
|
493
|
+
formatTime(currentSessionTime) + " / " + formatTime(displayEnd);
|
|
494
|
+
});
|
|
495
|
+
// Handle video end
|
|
496
|
+
videos[0].addEventListener("ended", () => {
|
|
497
|
+
isPlaying = false;
|
|
498
|
+
updatePlayPauseButton(false);
|
|
499
|
+
if (syncAnimationId) {
|
|
500
|
+
cancelAnimationFrame(syncAnimationId);
|
|
501
|
+
syncAnimationId = null;
|
|
502
|
+
}
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
playPauseBtn.addEventListener("click", async () => {
|
|
508
|
+
if (isPlaying) {
|
|
509
|
+
videos.forEach((v) => v.pause());
|
|
510
|
+
if (syncAnimationId) {
|
|
511
|
+
cancelAnimationFrame(syncAnimationId);
|
|
512
|
+
syncAnimationId = null;
|
|
513
|
+
}
|
|
514
|
+
isPlaying = false;
|
|
515
|
+
updatePlayPauseButton(false);
|
|
516
|
+
} else {
|
|
517
|
+
// Play all videos and wait for them to start
|
|
518
|
+
const playPromises = videos.map((v) =>
|
|
519
|
+
v.play().catch((err) => {
|
|
520
|
+
console.warn("Video play failed:", err);
|
|
521
|
+
})
|
|
522
|
+
);
|
|
523
|
+
await Promise.all(playPromises);
|
|
524
|
+
isPlaying = true;
|
|
525
|
+
updatePlayPauseButton(true);
|
|
526
|
+
syncVideos(); // Start synchronization loop
|
|
527
|
+
}
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
seekBar.addEventListener("input", () => {
|
|
531
|
+
const time = parseFloat(seekBar.value);
|
|
532
|
+
videos.forEach((v) => (v.currentTime = time));
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
settingsBtn.addEventListener("click", () => {
|
|
536
|
+
const isOpen = model.get("settings_open");
|
|
537
|
+
model.set("settings_open", !isOpen);
|
|
538
|
+
model.save_changes();
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
model.on("change:video_urls", updateVideos);
|
|
542
|
+
model.on("change:selected_videos", () => {
|
|
543
|
+
updateVideos();
|
|
544
|
+
updateSettingsPanel();
|
|
545
|
+
});
|
|
546
|
+
model.on("change:layout_mode", updateVideos);
|
|
547
|
+
model.on("change:settings_open", updateSettingsPanel);
|
|
548
|
+
model.on("change:available_videos", updateSettingsPanel);
|
|
549
|
+
|
|
550
|
+
updateVideos();
|
|
551
|
+
updateSettingsPanel();
|
|
552
|
+
|
|
553
|
+
wrapper.appendChild(settingsPanel);
|
|
554
|
+
wrapper.appendChild(gridContainer);
|
|
555
|
+
wrapper.appendChild(controls);
|
|
556
|
+
el.appendChild(wrapper);
|
|
557
|
+
|
|
558
|
+
// Cleanup function (called when widget is destroyed)
|
|
559
|
+
return () => {
|
|
560
|
+
if (syncAnimationId) {
|
|
561
|
+
cancelAnimationFrame(syncAnimationId);
|
|
562
|
+
}
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
export default { render };
|