retold-remote 0.0.1 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/html/index.html +2 -0
  2. package/package.json +20 -14
  3. package/source/Pict-Application-RetoldRemote.js +46 -5
  4. package/source/cli/RetoldRemote-CLI-Run.js +0 -0
  5. package/source/cli/RetoldRemote-Server-Setup.js +790 -8
  6. package/source/cli/commands/RetoldRemote-Command-Serve.js +34 -1
  7. package/source/providers/Pict-Provider-GalleryFilterSort.js +61 -9
  8. package/source/providers/Pict-Provider-GalleryNavigation.js +517 -18
  9. package/source/providers/Pict-Provider-RetoldRemote.js +11 -2
  10. package/source/providers/Pict-Provider-RetoldRemoteIcons.js +1 -0
  11. package/source/server/RetoldRemote-ArchiveService.js +830 -0
  12. package/source/server/RetoldRemote-AudioWaveformService.js +673 -0
  13. package/source/server/RetoldRemote-EbookService.js +242 -0
  14. package/source/server/RetoldRemote-MediaService.js +1 -1
  15. package/source/server/RetoldRemote-ToolDetector.js +31 -1
  16. package/source/server/RetoldRemote-VideoFrameService.js +486 -0
  17. package/source/views/PictView-Remote-AudioExplorer.js +1213 -0
  18. package/source/views/PictView-Remote-Gallery.js +141 -2
  19. package/source/views/PictView-Remote-Layout.js +18 -27
  20. package/source/views/PictView-Remote-MediaViewer.js +638 -39
  21. package/source/views/PictView-Remote-SettingsPanel.js +23 -0
  22. package/source/views/PictView-Remote-TopBar.js +121 -0
  23. package/source/views/PictView-Remote-VideoExplorer.js +1229 -0
  24. package/web-application/index.html +2 -0
  25. package/web-application/js/epub.min.js +1 -0
  26. package/web-application/retold-remote.js +7030 -1244
  27. package/web-application/retold-remote.js.map +1 -1
  28. package/web-application/retold-remote.min.js +13 -44
  29. package/web-application/retold-remote.min.js.map +1 -1
  30. package/web-application/retold-remote.compatible.js +0 -5764
  31. package/web-application/retold-remote.compatible.js.map +0 -1
  32. package/web-application/retold-remote.compatible.min.js +0 -120
  33. package/web-application/retold-remote.compatible.min.js.map +0 -1
@@ -0,0 +1,1229 @@
1
+ const libPictView = require('pict-view');
2
+
3
+ const _ViewConfiguration =
4
+ {
5
+ ViewIdentifier: "RetoldRemote-VideoExplorer",
6
+ DefaultRenderable: "RetoldRemote-VideoExplorer",
7
+ DefaultDestinationAddress: "#RetoldRemote-Viewer-Container",
8
+ AutoRender: false,
9
+
10
+ CSS: /*css*/`
11
+ .retold-remote-vex
12
+ {
13
+ display: flex;
14
+ flex-direction: column;
15
+ height: 100%;
16
+ }
17
+ .retold-remote-vex-header
18
+ {
19
+ display: flex;
20
+ align-items: center;
21
+ gap: 12px;
22
+ padding: 8px 16px;
23
+ background: var(--retold-bg-secondary);
24
+ border-bottom: 1px solid var(--retold-border);
25
+ flex-shrink: 0;
26
+ z-index: 5;
27
+ }
28
+ .retold-remote-vex-nav-btn
29
+ {
30
+ padding: 4px 10px;
31
+ border: 1px solid var(--retold-border);
32
+ border-radius: 3px;
33
+ background: transparent;
34
+ color: var(--retold-text-muted);
35
+ font-size: 0.8rem;
36
+ cursor: pointer;
37
+ transition: color 0.15s, border-color 0.15s;
38
+ font-family: inherit;
39
+ }
40
+ .retold-remote-vex-nav-btn:hover
41
+ {
42
+ color: var(--retold-text-primary);
43
+ border-color: var(--retold-accent);
44
+ }
45
+ .retold-remote-vex-title
46
+ {
47
+ flex: 1;
48
+ font-size: 0.82rem;
49
+ color: var(--retold-text-secondary);
50
+ overflow: hidden;
51
+ text-overflow: ellipsis;
52
+ white-space: nowrap;
53
+ text-align: center;
54
+ }
55
+ .retold-remote-vex-info
56
+ {
57
+ display: flex;
58
+ align-items: center;
59
+ gap: 16px;
60
+ padding: 8px 16px;
61
+ background: var(--retold-bg-tertiary);
62
+ border-bottom: 1px solid var(--retold-border);
63
+ flex-shrink: 0;
64
+ font-size: 0.75rem;
65
+ color: var(--retold-text-dim);
66
+ }
67
+ .retold-remote-vex-info-item
68
+ {
69
+ display: inline-flex;
70
+ align-items: center;
71
+ gap: 4px;
72
+ }
73
+ .retold-remote-vex-info-label
74
+ {
75
+ color: var(--retold-text-muted);
76
+ }
77
+ .retold-remote-vex-info-value
78
+ {
79
+ color: var(--retold-text-secondary);
80
+ }
81
+ .retold-remote-vex-controls
82
+ {
83
+ display: flex;
84
+ align-items: center;
85
+ gap: 12px;
86
+ padding: 8px 16px;
87
+ background: var(--retold-bg-secondary);
88
+ border-bottom: 1px solid var(--retold-border);
89
+ flex-shrink: 0;
90
+ }
91
+ .retold-remote-vex-controls label
92
+ {
93
+ font-size: 0.75rem;
94
+ color: var(--retold-text-muted);
95
+ }
96
+ .retold-remote-vex-controls select,
97
+ .retold-remote-vex-controls input[type="range"]
98
+ {
99
+ font-size: 0.75rem;
100
+ background: var(--retold-bg-tertiary);
101
+ color: var(--retold-text-primary);
102
+ border: 1px solid var(--retold-border);
103
+ border-radius: 3px;
104
+ padding: 2px 6px;
105
+ font-family: inherit;
106
+ }
107
+ .retold-remote-vex-controls .retold-remote-vex-refresh-btn
108
+ {
109
+ padding: 3px 12px;
110
+ border: 1px solid var(--retold-accent);
111
+ border-radius: 3px;
112
+ background: transparent;
113
+ color: var(--retold-accent);
114
+ font-size: 0.75rem;
115
+ cursor: pointer;
116
+ transition: background 0.15s, color 0.15s;
117
+ font-family: inherit;
118
+ }
119
+ .retold-remote-vex-controls .retold-remote-vex-refresh-btn:hover
120
+ {
121
+ background: var(--retold-accent);
122
+ color: var(--retold-bg-primary);
123
+ }
124
+ .retold-remote-vex-body
125
+ {
126
+ flex: 1;
127
+ overflow-y: auto;
128
+ padding: 16px;
129
+ }
130
+ .retold-remote-vex-loading
131
+ {
132
+ display: flex;
133
+ flex-direction: column;
134
+ align-items: center;
135
+ justify-content: center;
136
+ height: 100%;
137
+ color: var(--retold-text-dim);
138
+ font-size: 0.9rem;
139
+ }
140
+ .retold-remote-vex-loading-spinner
141
+ {
142
+ width: 32px;
143
+ height: 32px;
144
+ border: 3px solid var(--retold-border);
145
+ border-top-color: var(--retold-accent);
146
+ border-radius: 50%;
147
+ animation: retold-vex-spin 0.8s linear infinite;
148
+ margin-bottom: 16px;
149
+ }
150
+ @keyframes retold-vex-spin
151
+ {
152
+ to { transform: rotate(360deg); }
153
+ }
154
+ .retold-remote-vex-grid
155
+ {
156
+ display: grid;
157
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
158
+ gap: 12px;
159
+ }
160
+ .retold-remote-vex-frame
161
+ {
162
+ position: relative;
163
+ border-radius: 6px;
164
+ overflow: hidden;
165
+ background: var(--retold-bg-tertiary);
166
+ border: 2px solid transparent;
167
+ cursor: pointer;
168
+ transition: border-color 0.15s, transform 0.1s;
169
+ }
170
+ .retold-remote-vex-frame:hover
171
+ {
172
+ border-color: var(--retold-accent);
173
+ transform: translateY(-1px);
174
+ }
175
+ .retold-remote-vex-frame.selected
176
+ {
177
+ border-color: var(--retold-accent);
178
+ }
179
+ .retold-remote-vex-frame img
180
+ {
181
+ width: 100%;
182
+ display: block;
183
+ aspect-ratio: 16 / 9;
184
+ object-fit: contain;
185
+ background: #000;
186
+ }
187
+ .retold-remote-vex-frame-info
188
+ {
189
+ display: flex;
190
+ align-items: center;
191
+ justify-content: space-between;
192
+ padding: 6px 10px;
193
+ background: var(--retold-bg-secondary);
194
+ }
195
+ .retold-remote-vex-frame-timestamp
196
+ {
197
+ font-size: 0.78rem;
198
+ color: var(--retold-text-secondary);
199
+ font-family: var(--retold-font-mono, monospace);
200
+ }
201
+ .retold-remote-vex-frame-index
202
+ {
203
+ font-size: 0.7rem;
204
+ color: var(--retold-text-dim);
205
+ }
206
+ .retold-remote-vex-frame.custom-frame
207
+ {
208
+ border-color: var(--retold-accent);
209
+ border-style: dashed;
210
+ }
211
+ .retold-remote-vex-frame.custom-frame .retold-remote-vex-frame-info
212
+ {
213
+ background: color-mix(in srgb, var(--retold-accent) 12%, var(--retold-bg-secondary));
214
+ }
215
+ .retold-remote-vex-frame-loading
216
+ {
217
+ width: 100%;
218
+ aspect-ratio: 16 / 9;
219
+ display: flex;
220
+ align-items: center;
221
+ justify-content: center;
222
+ background: #000;
223
+ color: var(--retold-text-dim);
224
+ font-size: 0.8rem;
225
+ }
226
+ .retold-remote-vex-timeline-marker.custom
227
+ {
228
+ background: var(--retold-text-primary);
229
+ opacity: 0.9;
230
+ width: 2px;
231
+ border: 1px dashed var(--retold-accent);
232
+ }
233
+ /* Timeline bar at bottom */
234
+ .retold-remote-vex-timeline
235
+ {
236
+ display: flex;
237
+ align-items: center;
238
+ gap: 8px;
239
+ padding: 8px 16px;
240
+ background: var(--retold-bg-secondary);
241
+ border-top: 1px solid var(--retold-border);
242
+ flex-shrink: 0;
243
+ }
244
+ .retold-remote-vex-timeline-bar
245
+ {
246
+ flex: 1;
247
+ height: 24px;
248
+ background: var(--retold-bg-tertiary);
249
+ border-radius: 4px;
250
+ position: relative;
251
+ overflow: hidden;
252
+ cursor: pointer;
253
+ }
254
+ .retold-remote-vex-timeline-marker
255
+ {
256
+ position: absolute;
257
+ top: 0;
258
+ width: 3px;
259
+ height: 100%;
260
+ background: var(--retold-accent);
261
+ opacity: 0.7;
262
+ transition: opacity 0.15s;
263
+ }
264
+ .retold-remote-vex-timeline-marker:hover
265
+ {
266
+ opacity: 1;
267
+ }
268
+ .retold-remote-vex-timeline-marker.selected
269
+ {
270
+ opacity: 1;
271
+ background: var(--retold-text-primary);
272
+ }
273
+ .retold-remote-vex-timeline-label
274
+ {
275
+ font-size: 0.7rem;
276
+ color: var(--retold-text-dim);
277
+ white-space: nowrap;
278
+ }
279
+ /* Frame preview overlay */
280
+ .retold-remote-vex-preview-backdrop
281
+ {
282
+ position: fixed;
283
+ top: 0;
284
+ left: 0;
285
+ width: 100%;
286
+ height: 100%;
287
+ background: rgba(0, 0, 0, 0.85);
288
+ z-index: 100;
289
+ display: flex;
290
+ flex-direction: column;
291
+ align-items: center;
292
+ justify-content: center;
293
+ }
294
+ .retold-remote-vex-preview-header
295
+ {
296
+ display: flex;
297
+ align-items: center;
298
+ gap: 12px;
299
+ padding: 8px 16px;
300
+ width: 100%;
301
+ max-width: 95vw;
302
+ flex-shrink: 0;
303
+ }
304
+ .retold-remote-vex-preview-header .retold-remote-vex-nav-btn
305
+ {
306
+ background: rgba(40, 44, 52, 0.8);
307
+ }
308
+ .retold-remote-vex-preview-title
309
+ {
310
+ flex: 1;
311
+ font-size: 0.82rem;
312
+ color: var(--retold-text-secondary);
313
+ text-align: center;
314
+ }
315
+ .retold-remote-vex-preview-body
316
+ {
317
+ flex: 1;
318
+ display: flex;
319
+ align-items: center;
320
+ justify-content: center;
321
+ overflow: auto;
322
+ padding: 8px;
323
+ max-width: 95vw;
324
+ max-height: calc(100vh - 60px);
325
+ }
326
+ .retold-remote-vex-preview-body img
327
+ {
328
+ max-width: 100%;
329
+ max-height: 100%;
330
+ object-fit: contain;
331
+ border-radius: 4px;
332
+ box-shadow: 0 4px 24px rgba(0, 0, 0, 0.6);
333
+ }
334
+ /* Error state */
335
+ .retold-remote-vex-error
336
+ {
337
+ display: flex;
338
+ flex-direction: column;
339
+ align-items: center;
340
+ justify-content: center;
341
+ height: 100%;
342
+ color: var(--retold-text-dim);
343
+ font-size: 0.85rem;
344
+ text-align: center;
345
+ padding: 40px;
346
+ }
347
+ .retold-remote-vex-error-message
348
+ {
349
+ color: #e06c75;
350
+ margin-bottom: 16px;
351
+ }
352
+ `
353
+ };
354
+
355
+ class RetoldRemoteVideoExplorerView extends libPictView
356
+ {
357
+ constructor(pFable, pOptions, pServiceHash)
358
+ {
359
+ super(pFable, pOptions, pServiceHash);
360
+
361
+ this._currentPath = '';
362
+ this._frameData = null;
363
+ this._selectedFrameIndex = -1;
364
+ this._frameCount = 20;
365
+ this._fullResFrames = false;
366
+ this._customFrames = [];
367
+ }
368
+
369
+ /**
370
+ * Show the video explorer for a given video file.
371
+ *
372
+ * @param {string} pFilePath - Relative file path
373
+ */
374
+ showExplorer(pFilePath)
375
+ {
376
+ let tmpRemote = this.pict.AppData.RetoldRemote;
377
+ tmpRemote.ActiveMode = 'video-explorer';
378
+ this._currentPath = pFilePath;
379
+ this._frameData = null;
380
+ this._selectedFrameIndex = -1;
381
+ this._customFrames = [];
382
+
383
+ // Update the hash
384
+ let tmpFragProvider = this.pict.providers['RetoldRemote-Provider'];
385
+ let tmpFragId = tmpFragProvider ? tmpFragProvider.getFragmentIdentifier(pFilePath) : pFilePath;
386
+ window.location.hash = '#/explore/' + tmpFragId;
387
+
388
+ // Show viewer container, hide gallery
389
+ let tmpGalleryContainer = document.getElementById('RetoldRemote-Gallery-Container');
390
+ let tmpViewerContainer = document.getElementById('RetoldRemote-Viewer-Container');
391
+
392
+ if (tmpGalleryContainer) tmpGalleryContainer.style.display = 'none';
393
+ if (tmpViewerContainer) tmpViewerContainer.style.display = 'block';
394
+
395
+ let tmpFileName = pFilePath.replace(/^.*\//, '');
396
+
397
+ // Build initial UI with loading state
398
+ let tmpHTML = '<div class="retold-remote-vex">';
399
+
400
+ // Header
401
+ tmpHTML += '<div class="retold-remote-vex-header">';
402
+ tmpHTML += '<button class="retold-remote-vex-nav-btn" onclick="pict.views[\'RetoldRemote-VideoExplorer\'].goBack()" title="Back to video (Esc)">&larr; Back</button>';
403
+ tmpHTML += '<div class="retold-remote-vex-title">Video Explorer &mdash; ' + this._escapeHTML(tmpFileName) + '</div>';
404
+ tmpHTML += '</div>';
405
+
406
+ // Info bar (populated after frames load)
407
+ tmpHTML += '<div class="retold-remote-vex-info" id="RetoldRemote-VEX-Info" style="display:none;"></div>';
408
+
409
+ // Controls bar
410
+ tmpHTML += '<div class="retold-remote-vex-controls" id="RetoldRemote-VEX-Controls" style="display:none;">';
411
+ tmpHTML += '<label>Frames:</label>';
412
+ tmpHTML += '<select id="RetoldRemote-VEX-FrameCount" onchange="pict.views[\'RetoldRemote-VideoExplorer\'].onFrameCountChange(this.value)">';
413
+ tmpHTML += '<option value="10"' + (this._frameCount === 10 ? ' selected' : '') + '>10</option>';
414
+ tmpHTML += '<option value="20"' + (this._frameCount === 20 ? ' selected' : '') + '>20</option>';
415
+ tmpHTML += '<option value="40"' + (this._frameCount === 40 ? ' selected' : '') + '>40</option>';
416
+ tmpHTML += '<option value="60"' + (this._frameCount === 60 ? ' selected' : '') + '>60</option>';
417
+ tmpHTML += '<option value="100"' + (this._frameCount === 100 ? ' selected' : '') + '>100</option>';
418
+ tmpHTML += '</select>';
419
+ tmpHTML += '<label style="display:inline-flex;align-items:center;gap:4px;cursor:pointer;">';
420
+ tmpHTML += '<input type="checkbox" id="RetoldRemote-VEX-FullRes"' + (this._fullResFrames ? ' checked' : '') + ' onchange="pict.views[\'RetoldRemote-VideoExplorer\'].onFullResChange(this.checked)">';
421
+ tmpHTML += 'Full Res Frames</label>';
422
+ tmpHTML += '<button class="retold-remote-vex-refresh-btn" onclick="pict.views[\'RetoldRemote-VideoExplorer\'].refresh()">Refresh</button>';
423
+ tmpHTML += '</div>';
424
+
425
+ // Body (loading initially)
426
+ tmpHTML += '<div class="retold-remote-vex-body" id="RetoldRemote-VEX-Body">';
427
+ tmpHTML += '<div class="retold-remote-vex-loading">';
428
+ tmpHTML += '<div class="retold-remote-vex-loading-spinner"></div>';
429
+ tmpHTML += 'Extracting frames from video...';
430
+ tmpHTML += '</div>';
431
+ tmpHTML += '</div>';
432
+
433
+ // Timeline bar (populated after frames load)
434
+ tmpHTML += '<div class="retold-remote-vex-timeline" id="RetoldRemote-VEX-Timeline" style="display:none;"></div>';
435
+
436
+ tmpHTML += '</div>';
437
+
438
+ if (tmpViewerContainer)
439
+ {
440
+ tmpViewerContainer.innerHTML = tmpHTML;
441
+ }
442
+
443
+ // Update topbar
444
+ let tmpTopBar = this.pict.views['ContentEditor-TopBar'];
445
+ if (tmpTopBar)
446
+ {
447
+ tmpTopBar.updateInfo();
448
+ }
449
+
450
+ // Fetch frames
451
+ this._fetchFrames(pFilePath);
452
+ }
453
+
454
+ /**
455
+ * Fetch video frames from the server.
456
+ *
457
+ * @param {string} pFilePath - Relative file path
458
+ */
459
+ _fetchFrames(pFilePath)
460
+ {
461
+ let tmpSelf = this;
462
+ let tmpProvider = this.pict.providers['RetoldRemote-Provider'];
463
+ let tmpPathParam = tmpProvider ? tmpProvider._getPathParam(pFilePath) : encodeURIComponent(pFilePath);
464
+
465
+ let tmpURL = '/api/media/video-frames?path=' + tmpPathParam + '&count=' + this._frameCount;
466
+ if (this._fullResFrames)
467
+ {
468
+ tmpURL += '&width=1920&height=1080';
469
+ }
470
+
471
+ fetch(tmpURL)
472
+ .then((pResponse) => pResponse.json())
473
+ .then((pData) =>
474
+ {
475
+ if (!pData || !pData.Success)
476
+ {
477
+ tmpSelf._showError(pData ? pData.Error : 'Unknown error');
478
+ return;
479
+ }
480
+
481
+ tmpSelf._frameData = pData;
482
+ tmpSelf._renderFrames();
483
+ })
484
+ .catch((pError) =>
485
+ {
486
+ tmpSelf._showError(pError.message);
487
+ });
488
+ }
489
+
490
+ /**
491
+ * Render the extracted frames into the grid.
492
+ */
493
+ _renderFrames()
494
+ {
495
+ let tmpData = this._frameData;
496
+ if (!tmpData)
497
+ {
498
+ return;
499
+ }
500
+
501
+ // Populate info bar
502
+ let tmpInfoBar = document.getElementById('RetoldRemote-VEX-Info');
503
+ if (tmpInfoBar)
504
+ {
505
+ let tmpInfoHTML = '';
506
+ tmpInfoHTML += '<span class="retold-remote-vex-info-item"><span class="retold-remote-vex-info-label">Duration</span> <span class="retold-remote-vex-info-value">' + this._escapeHTML(tmpData.DurationFormatted) + '</span></span>';
507
+ if (tmpData.VideoWidth && tmpData.VideoHeight)
508
+ {
509
+ tmpInfoHTML += '<span class="retold-remote-vex-info-item"><span class="retold-remote-vex-info-label">Resolution</span> <span class="retold-remote-vex-info-value">' + tmpData.VideoWidth + '&times;' + tmpData.VideoHeight + '</span></span>';
510
+ }
511
+ if (tmpData.Codec)
512
+ {
513
+ tmpInfoHTML += '<span class="retold-remote-vex-info-item"><span class="retold-remote-vex-info-label">Codec</span> <span class="retold-remote-vex-info-value">' + this._escapeHTML(tmpData.Codec) + '</span></span>';
514
+ }
515
+ if (tmpData.FileSize)
516
+ {
517
+ tmpInfoHTML += '<span class="retold-remote-vex-info-item"><span class="retold-remote-vex-info-label">Size</span> <span class="retold-remote-vex-info-value">' + this._formatFileSize(tmpData.FileSize) + '</span></span>';
518
+ }
519
+ tmpInfoHTML += '<span class="retold-remote-vex-info-item"><span class="retold-remote-vex-info-label">Frames</span> <span class="retold-remote-vex-info-value">' + tmpData.FrameCount + '</span></span>';
520
+
521
+ tmpInfoBar.innerHTML = tmpInfoHTML;
522
+ tmpInfoBar.style.display = '';
523
+ }
524
+
525
+ // Show controls
526
+ let tmpControlsBar = document.getElementById('RetoldRemote-VEX-Controls');
527
+ if (tmpControlsBar)
528
+ {
529
+ tmpControlsBar.style.display = '';
530
+ }
531
+
532
+ // Render the frame grid
533
+ let tmpBody = document.getElementById('RetoldRemote-VEX-Body');
534
+ if (tmpBody)
535
+ {
536
+ let tmpGridHTML = '<div class="retold-remote-vex-grid">';
537
+
538
+ for (let i = 0; i < tmpData.Frames.length; i++)
539
+ {
540
+ let tmpFrame = tmpData.Frames[i];
541
+ let tmpFrameURL = '/api/media/video-frame/' + tmpData.CacheKey + '/' + tmpFrame.Filename;
542
+
543
+ tmpGridHTML += '<div class="retold-remote-vex-frame" id="retold-vex-frame-' + i + '" onclick="pict.views[\'RetoldRemote-VideoExplorer\'].selectFrame(' + i + ')" ondblclick="pict.views[\'RetoldRemote-VideoExplorer\'].openFrameFullsize(' + i + ')">';
544
+ tmpGridHTML += '<img src="' + tmpFrameURL + '" alt="Frame at ' + this._escapeHTML(tmpFrame.TimestampFormatted) + '" loading="lazy">';
545
+ tmpGridHTML += '<div class="retold-remote-vex-frame-info">';
546
+ tmpGridHTML += '<span class="retold-remote-vex-frame-timestamp">' + this._escapeHTML(tmpFrame.TimestampFormatted) + '</span>';
547
+ tmpGridHTML += '<span class="retold-remote-vex-frame-index">#' + (tmpFrame.Index + 1) + '</span>';
548
+ tmpGridHTML += '</div>';
549
+ tmpGridHTML += '</div>';
550
+ }
551
+
552
+ tmpGridHTML += '</div>';
553
+ tmpBody.innerHTML = tmpGridHTML;
554
+ }
555
+
556
+ // Render the timeline
557
+ this._renderTimeline();
558
+ }
559
+
560
+ /**
561
+ * Render the timeline bar at the bottom.
562
+ */
563
+ _renderTimeline()
564
+ {
565
+ let tmpData = this._frameData;
566
+ if (!tmpData || !tmpData.Duration)
567
+ {
568
+ return;
569
+ }
570
+
571
+ let tmpTimeline = document.getElementById('RetoldRemote-VEX-Timeline');
572
+ if (!tmpTimeline)
573
+ {
574
+ return;
575
+ }
576
+
577
+ let tmpHTML = '';
578
+ tmpHTML += '<span class="retold-remote-vex-timeline-label">0:00</span>';
579
+ tmpHTML += '<div class="retold-remote-vex-timeline-bar" id="RetoldRemote-VEX-TimelineBar" '
580
+ + 'onclick="pict.views[\'RetoldRemote-VideoExplorer\'].onTimelineClick(event)">';
581
+
582
+ for (let i = 0; i < tmpData.Frames.length; i++)
583
+ {
584
+ let tmpFrame = tmpData.Frames[i];
585
+ let tmpPercent = (tmpFrame.Timestamp / tmpData.Duration) * 100;
586
+ let tmpSelectedClass = (i === this._selectedFrameIndex) ? ' selected' : '';
587
+ tmpHTML += '<div class="retold-remote-vex-timeline-marker' + tmpSelectedClass + '" '
588
+ + 'style="left:' + tmpPercent.toFixed(2) + '%;" '
589
+ + 'title="' + this._escapeHTML(tmpFrame.TimestampFormatted) + '" '
590
+ + 'onclick="event.stopPropagation(); pict.views[\'RetoldRemote-VideoExplorer\'].selectFrame(' + i + ')">'
591
+ + '</div>';
592
+ }
593
+
594
+ // Also render markers for custom frames
595
+ if (this._customFrames)
596
+ {
597
+ for (let i = 0; i < this._customFrames.length; i++)
598
+ {
599
+ let tmpCustom = this._customFrames[i];
600
+ let tmpPercent = (tmpCustom.Timestamp / tmpData.Duration) * 100;
601
+ tmpHTML += '<div class="retold-remote-vex-timeline-marker custom" '
602
+ + 'style="left:' + tmpPercent.toFixed(2) + '%;" '
603
+ + 'title="' + this._escapeHTML(tmpCustom.TimestampFormatted) + '">'
604
+ + '</div>';
605
+ }
606
+ }
607
+
608
+ tmpHTML += '</div>';
609
+ tmpHTML += '<span class="retold-remote-vex-timeline-label">' + this._escapeHTML(tmpData.DurationFormatted) + '</span>';
610
+
611
+ tmpTimeline.innerHTML = tmpHTML;
612
+ tmpTimeline.style.display = '';
613
+ }
614
+
615
+ /**
616
+ * Select a frame by index (highlight it in grid and timeline).
617
+ *
618
+ * @param {number} pIndex - Frame index
619
+ */
620
+ selectFrame(pIndex)
621
+ {
622
+ // Deselect previous
623
+ if (this._selectedFrameIndex >= 0)
624
+ {
625
+ let tmpPrevFrame = document.getElementById('retold-vex-frame-' + this._selectedFrameIndex);
626
+ if (tmpPrevFrame)
627
+ {
628
+ tmpPrevFrame.classList.remove('selected');
629
+ }
630
+ }
631
+
632
+ this._selectedFrameIndex = pIndex;
633
+
634
+ // Select new
635
+ let tmpFrame = document.getElementById('retold-vex-frame-' + pIndex);
636
+ if (tmpFrame)
637
+ {
638
+ tmpFrame.classList.add('selected');
639
+ tmpFrame.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
640
+ }
641
+
642
+ // Update timeline markers
643
+ this._updateTimelineSelection();
644
+ }
645
+
646
+ /**
647
+ * Update the timeline marker selection state.
648
+ */
649
+ _updateTimelineSelection()
650
+ {
651
+ let tmpBar = document.getElementById('RetoldRemote-VEX-TimelineBar');
652
+ if (!tmpBar)
653
+ {
654
+ return;
655
+ }
656
+
657
+ let tmpMarkers = tmpBar.querySelectorAll('.retold-remote-vex-timeline-marker');
658
+ for (let i = 0; i < tmpMarkers.length; i++)
659
+ {
660
+ if (i === this._selectedFrameIndex)
661
+ {
662
+ tmpMarkers[i].classList.add('selected');
663
+ }
664
+ else
665
+ {
666
+ tmpMarkers[i].classList.remove('selected');
667
+ }
668
+ }
669
+ }
670
+
671
+ /**
672
+ * Handle a click on the timeline bar (not on a marker).
673
+ * Calculates the timestamp from the click position and extracts a single frame.
674
+ *
675
+ * @param {MouseEvent} pEvent - The click event
676
+ */
677
+ onTimelineClick(pEvent)
678
+ {
679
+ let tmpData = this._frameData;
680
+ if (!tmpData || !tmpData.Duration || !tmpData.CacheKey)
681
+ {
682
+ return;
683
+ }
684
+
685
+ let tmpBar = document.getElementById('RetoldRemote-VEX-TimelineBar');
686
+ if (!tmpBar)
687
+ {
688
+ return;
689
+ }
690
+
691
+ // Calculate timestamp from click position
692
+ let tmpRect = tmpBar.getBoundingClientRect();
693
+ let tmpClickX = pEvent.clientX - tmpRect.left;
694
+ let tmpPercent = Math.max(0, Math.min(1, tmpClickX / tmpRect.width));
695
+ let tmpTimestamp = tmpPercent * tmpData.Duration;
696
+
697
+ let tmpSelf = this;
698
+ let tmpProvider = this.pict.providers['RetoldRemote-Provider'];
699
+ let tmpPathParam = tmpProvider ? tmpProvider._getPathParam(this._currentPath) : encodeURIComponent(this._currentPath);
700
+
701
+ // Build the URL — pass the same resolution settings as the initial extraction
702
+ let tmpURL = '/api/media/video-frame-at?path=' + tmpPathParam
703
+ + '&cacheKey=' + encodeURIComponent(tmpData.CacheKey)
704
+ + '&timestamp=' + tmpTimestamp.toFixed(3);
705
+
706
+ if (this._fullResFrames)
707
+ {
708
+ tmpURL += '&width=1920&height=1080';
709
+ }
710
+
711
+ // Insert a placeholder into the grid immediately
712
+ let tmpPlaceholderId = 'retold-vex-custom-' + Date.now();
713
+ this._insertFramePlaceholder(tmpTimestamp, tmpPlaceholderId);
714
+
715
+ fetch(tmpURL)
716
+ .then((pResponse) => pResponse.json())
717
+ .then((pResult) =>
718
+ {
719
+ if (!pResult || !pResult.Success)
720
+ {
721
+ throw new Error(pResult ? pResult.Error : 'Extraction failed.');
722
+ }
723
+
724
+ // Store the custom frame
725
+ tmpSelf._customFrames.push(pResult);
726
+
727
+ // Replace the placeholder with the real frame
728
+ let tmpPlaceholder = document.getElementById(tmpPlaceholderId);
729
+ if (tmpPlaceholder)
730
+ {
731
+ let tmpFrameURL = '/api/media/video-frame/' + tmpData.CacheKey + '/' + pResult.Filename;
732
+ let tmpEscFilename = tmpSelf._escapeHTML(pResult.Filename).replace(/'/g, "\\'");
733
+ let tmpEscTimestamp = tmpSelf._escapeHTML(pResult.TimestampFormatted).replace(/'/g, "\\'");
734
+ tmpPlaceholder.ondblclick = function() { pict.views['RetoldRemote-VideoExplorer'].openCustomFrameFullsize(tmpEscFilename, tmpEscTimestamp); };
735
+ tmpPlaceholder.style.cursor = 'pointer';
736
+ tmpPlaceholder.innerHTML = '<img src="' + tmpFrameURL + '" alt="Frame at ' + tmpSelf._escapeHTML(pResult.TimestampFormatted) + '" loading="lazy">'
737
+ + '<div class="retold-remote-vex-frame-info">'
738
+ + '<span class="retold-remote-vex-frame-timestamp">' + tmpSelf._escapeHTML(pResult.TimestampFormatted) + '</span>'
739
+ + '<span class="retold-remote-vex-frame-index">custom</span>'
740
+ + '</div>';
741
+ }
742
+
743
+ // Re-render timeline to show the new custom marker
744
+ tmpSelf._renderTimeline();
745
+ })
746
+ .catch((pError) =>
747
+ {
748
+ let tmpPlaceholder = document.getElementById(tmpPlaceholderId);
749
+ if (tmpPlaceholder)
750
+ {
751
+ tmpPlaceholder.innerHTML = '<div class="retold-remote-vex-frame-loading">Failed: ' + tmpSelf._escapeHTML(pError.message) + '</div>'
752
+ + '<div class="retold-remote-vex-frame-info">'
753
+ + '<span class="retold-remote-vex-frame-timestamp">' + tmpSelf._formatTimestamp(tmpTimestamp) + '</span>'
754
+ + '<span class="retold-remote-vex-frame-index">error</span>'
755
+ + '</div>';
756
+ }
757
+ });
758
+ }
759
+
760
+ /**
761
+ * Insert a loading placeholder frame card into the grid at the correct
762
+ * chronological position based on timestamp.
763
+ *
764
+ * @param {number} pTimestamp - Timestamp in seconds
765
+ * @param {string} pPlaceholderId - DOM id for the placeholder element
766
+ */
767
+ _insertFramePlaceholder(pTimestamp, pPlaceholderId)
768
+ {
769
+ let tmpGrid = document.querySelector('.retold-remote-vex-grid');
770
+ if (!tmpGrid)
771
+ {
772
+ return;
773
+ }
774
+
775
+ let tmpData = this._frameData;
776
+
777
+ // Build the placeholder element
778
+ let tmpEl = document.createElement('div');
779
+ tmpEl.className = 'retold-remote-vex-frame custom-frame';
780
+ tmpEl.id = pPlaceholderId;
781
+ tmpEl.innerHTML = '<div class="retold-remote-vex-frame-loading">Extracting...</div>'
782
+ + '<div class="retold-remote-vex-frame-info">'
783
+ + '<span class="retold-remote-vex-frame-timestamp">' + this._formatTimestamp(pTimestamp) + '</span>'
784
+ + '<span class="retold-remote-vex-frame-index">custom</span>'
785
+ + '</div>';
786
+
787
+ // Find the correct insertion position by comparing timestamps
788
+ // The grid children correspond to tmpData.Frames (original batch) plus any previously inserted custom frames
789
+ let tmpInsertBefore = null;
790
+ let tmpChildren = tmpGrid.children;
791
+
792
+ for (let i = 0; i < tmpChildren.length; i++)
793
+ {
794
+ let tmpChild = tmpChildren[i];
795
+ // Get the timestamp from the info bar text
796
+ let tmpTsEl = tmpChild.querySelector('.retold-remote-vex-frame-timestamp');
797
+ if (tmpTsEl)
798
+ {
799
+ let tmpChildTimestamp = this._parseTimestamp(tmpTsEl.textContent);
800
+ if (tmpChildTimestamp > pTimestamp)
801
+ {
802
+ tmpInsertBefore = tmpChild;
803
+ break;
804
+ }
805
+ }
806
+ }
807
+
808
+ if (tmpInsertBefore)
809
+ {
810
+ tmpGrid.insertBefore(tmpEl, tmpInsertBefore);
811
+ }
812
+ else
813
+ {
814
+ tmpGrid.appendChild(tmpEl);
815
+ }
816
+
817
+ // Scroll the new element into view
818
+ tmpEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
819
+ }
820
+
821
+ /**
822
+ * Parse a formatted timestamp string back to seconds.
823
+ * Handles "M:SS", "MM:SS", and "H:MM:SS" formats.
824
+ *
825
+ * @param {string} pText - Formatted timestamp like "1:23" or "1:02:34"
826
+ * @returns {number} Seconds
827
+ */
828
+ _parseTimestamp(pText)
829
+ {
830
+ if (!pText) return 0;
831
+ let tmpParts = pText.trim().split(':');
832
+ if (tmpParts.length === 3)
833
+ {
834
+ return parseInt(tmpParts[0], 10) * 3600 + parseInt(tmpParts[1], 10) * 60 + parseInt(tmpParts[2], 10);
835
+ }
836
+ if (tmpParts.length === 2)
837
+ {
838
+ return parseInt(tmpParts[0], 10) * 60 + parseInt(tmpParts[1], 10);
839
+ }
840
+ return parseFloat(pText) || 0;
841
+ }
842
+
843
+ /**
844
+ * Format a timestamp in seconds to a human-readable string.
845
+ *
846
+ * @param {number} pSeconds - Timestamp in seconds
847
+ * @returns {string} Formatted string like "1:23" or "1:02:34"
848
+ */
849
+ _formatTimestamp(pSeconds)
850
+ {
851
+ let tmpHours = Math.floor(pSeconds / 3600);
852
+ let tmpMinutes = Math.floor((pSeconds % 3600) / 60);
853
+ let tmpSecs = Math.floor(pSeconds % 60);
854
+
855
+ if (tmpHours > 0)
856
+ {
857
+ return tmpHours + ':' + String(tmpMinutes).padStart(2, '0') + ':' + String(tmpSecs).padStart(2, '0');
858
+ }
859
+ return tmpMinutes + ':' + String(tmpSecs).padStart(2, '0');
860
+ }
861
+
862
+ /**
863
+ * Open a frame at full size in an inline preview overlay.
864
+ *
865
+ * @param {number} pIndex - Frame index in the regular Frames array
866
+ */
867
+ openFrameFullsize(pIndex)
868
+ {
869
+ if (!this._frameData || !this._frameData.Frames[pIndex])
870
+ {
871
+ return;
872
+ }
873
+
874
+ let tmpFrame = this._frameData.Frames[pIndex];
875
+ let tmpURL = '/api/media/video-frame/' + this._frameData.CacheKey + '/' + tmpFrame.Filename;
876
+ let tmpLabel = tmpFrame.TimestampFormatted + ' \u00b7 #' + (tmpFrame.Index + 1);
877
+
878
+ this._showFramePreview(tmpURL, tmpLabel, 'regular', pIndex);
879
+ }
880
+
881
+ /**
882
+ * Open a custom frame (from timeline click) in the preview overlay.
883
+ *
884
+ * @param {string} pFilename - Custom frame filename
885
+ * @param {string} pTimestamp - Formatted timestamp label
886
+ */
887
+ openCustomFrameFullsize(pFilename, pTimestamp)
888
+ {
889
+ if (!this._frameData)
890
+ {
891
+ return;
892
+ }
893
+
894
+ let tmpURL = '/api/media/video-frame/' + this._frameData.CacheKey + '/' + pFilename;
895
+ let tmpLabel = pTimestamp + ' \u00b7 custom';
896
+
897
+ // Find the custom frame index for navigation
898
+ let tmpCustomIndex = -1;
899
+ for (let i = 0; i < this._customFrames.length; i++)
900
+ {
901
+ if (this._customFrames[i].Filename === pFilename)
902
+ {
903
+ tmpCustomIndex = i;
904
+ break;
905
+ }
906
+ }
907
+
908
+ this._showFramePreview(tmpURL, tmpLabel, 'custom', tmpCustomIndex);
909
+ }
910
+
911
+ /**
912
+ * Build a sorted list of all frames (regular + custom) for navigation.
913
+ */
914
+ _buildAllFramesList()
915
+ {
916
+ let tmpAllFrames = [];
917
+
918
+ // Add regular frames
919
+ if (this._frameData && this._frameData.Frames)
920
+ {
921
+ for (let i = 0; i < this._frameData.Frames.length; i++)
922
+ {
923
+ let tmpFrame = this._frameData.Frames[i];
924
+ tmpAllFrames.push({
925
+ Type: 'regular',
926
+ Index: i,
927
+ Timestamp: tmpFrame.Timestamp,
928
+ TimestampFormatted: tmpFrame.TimestampFormatted,
929
+ Filename: tmpFrame.Filename,
930
+ Label: tmpFrame.TimestampFormatted + ' \u00b7 #' + (tmpFrame.Index + 1)
931
+ });
932
+ }
933
+ }
934
+
935
+ // Add custom frames
936
+ if (this._customFrames)
937
+ {
938
+ for (let i = 0; i < this._customFrames.length; i++)
939
+ {
940
+ let tmpCustom = this._customFrames[i];
941
+ tmpAllFrames.push({
942
+ Type: 'custom',
943
+ Index: i,
944
+ Timestamp: tmpCustom.Timestamp,
945
+ TimestampFormatted: tmpCustom.TimestampFormatted,
946
+ Filename: tmpCustom.Filename,
947
+ Label: tmpCustom.TimestampFormatted + ' \u00b7 custom'
948
+ });
949
+ }
950
+ }
951
+
952
+ // Sort by timestamp
953
+ tmpAllFrames.sort((a, b) => a.Timestamp - b.Timestamp);
954
+
955
+ return tmpAllFrames;
956
+ }
957
+
958
+ /**
959
+ * Show the frame preview overlay.
960
+ *
961
+ * @param {string} pURL - Frame image URL
962
+ * @param {string} pLabel - Frame label to display
963
+ * @param {string} pType - 'regular' or 'custom'
964
+ * @param {number} pIndex - Index within its type array
965
+ */
966
+ _showFramePreview(pURL, pLabel, pType, pIndex)
967
+ {
968
+ // Store current preview state for navigation
969
+ this._previewType = pType;
970
+ this._previewIndex = pIndex;
971
+
972
+ // Build all frames for prev/next navigation
973
+ let tmpAllFrames = this._buildAllFramesList();
974
+ this._previewAllFrames = tmpAllFrames;
975
+
976
+ // Find current position in the unified list
977
+ this._previewPosition = 0;
978
+ for (let i = 0; i < tmpAllFrames.length; i++)
979
+ {
980
+ if (tmpAllFrames[i].Type === pType && tmpAllFrames[i].Index === pIndex)
981
+ {
982
+ this._previewPosition = i;
983
+ break;
984
+ }
985
+ }
986
+
987
+ // Build the overlay
988
+ let tmpBackdrop = document.createElement('div');
989
+ tmpBackdrop.className = 'retold-remote-vex-preview-backdrop';
990
+ tmpBackdrop.id = 'RetoldRemote-VEX-Preview';
991
+ tmpBackdrop.onclick = (e) =>
992
+ {
993
+ if (e.target === tmpBackdrop)
994
+ {
995
+ this.closeFramePreview();
996
+ }
997
+ };
998
+
999
+ let tmpHTML = '';
1000
+ tmpHTML += '<div class="retold-remote-vex-preview-header">';
1001
+ tmpHTML += '<button class="retold-remote-vex-nav-btn" onclick="pict.views[\'RetoldRemote-VideoExplorer\'].closeFramePreview()" title="Back (Esc)">&larr; Back</button>';
1002
+ tmpHTML += '<button class="retold-remote-vex-nav-btn" onclick="pict.views[\'RetoldRemote-VideoExplorer\'].previewPrevFrame()" title="Previous (\u2190)">&lsaquo; Prev</button>';
1003
+ tmpHTML += '<div class="retold-remote-vex-preview-title" id="RetoldRemote-VEX-PreviewTitle">' + this._escapeHTML(pLabel) + '</div>';
1004
+ tmpHTML += '<button class="retold-remote-vex-nav-btn" onclick="pict.views[\'RetoldRemote-VideoExplorer\'].previewNextFrame()" title="Next (\u2192)">Next &rsaquo;</button>';
1005
+ tmpHTML += '</div>';
1006
+ tmpHTML += '<div class="retold-remote-vex-preview-body" id="RetoldRemote-VEX-PreviewBody">';
1007
+ tmpHTML += '<img src="' + pURL + '" alt="' + this._escapeHTML(pLabel) + '">';
1008
+ tmpHTML += '</div>';
1009
+
1010
+ tmpBackdrop.innerHTML = tmpHTML;
1011
+ document.body.appendChild(tmpBackdrop);
1012
+
1013
+ // Bind keyboard handler (stopImmediatePropagation prevents the global handler from also firing)
1014
+ this._previewKeyHandler = (e) =>
1015
+ {
1016
+ switch (e.key)
1017
+ {
1018
+ case 'Escape':
1019
+ e.preventDefault();
1020
+ e.stopImmediatePropagation();
1021
+ this.closeFramePreview();
1022
+ break;
1023
+ case 'ArrowLeft':
1024
+ case 'k':
1025
+ e.preventDefault();
1026
+ e.stopImmediatePropagation();
1027
+ this.previewPrevFrame();
1028
+ break;
1029
+ case 'ArrowRight':
1030
+ case 'j':
1031
+ e.preventDefault();
1032
+ e.stopImmediatePropagation();
1033
+ this.previewNextFrame();
1034
+ break;
1035
+ }
1036
+ };
1037
+ document.addEventListener('keydown', this._previewKeyHandler);
1038
+ }
1039
+
1040
+ /**
1041
+ * Close the frame preview overlay.
1042
+ */
1043
+ closeFramePreview()
1044
+ {
1045
+ let tmpBackdrop = document.getElementById('RetoldRemote-VEX-Preview');
1046
+ if (tmpBackdrop)
1047
+ {
1048
+ tmpBackdrop.remove();
1049
+ }
1050
+
1051
+ if (this._previewKeyHandler)
1052
+ {
1053
+ document.removeEventListener('keydown', this._previewKeyHandler);
1054
+ this._previewKeyHandler = null;
1055
+ }
1056
+ }
1057
+
1058
+ /**
1059
+ * Navigate to the previous frame in the preview.
1060
+ */
1061
+ previewPrevFrame()
1062
+ {
1063
+ if (!this._previewAllFrames || this._previewPosition <= 0)
1064
+ {
1065
+ return;
1066
+ }
1067
+
1068
+ this._previewPosition--;
1069
+ this._updatePreviewFrame();
1070
+ }
1071
+
1072
+ /**
1073
+ * Navigate to the next frame in the preview.
1074
+ */
1075
+ previewNextFrame()
1076
+ {
1077
+ if (!this._previewAllFrames || this._previewPosition >= this._previewAllFrames.length - 1)
1078
+ {
1079
+ return;
1080
+ }
1081
+
1082
+ this._previewPosition++;
1083
+ this._updatePreviewFrame();
1084
+ }
1085
+
1086
+ /**
1087
+ * Update the preview to show the frame at the current position.
1088
+ */
1089
+ _updatePreviewFrame()
1090
+ {
1091
+ let tmpFrame = this._previewAllFrames[this._previewPosition];
1092
+ if (!tmpFrame || !this._frameData)
1093
+ {
1094
+ return;
1095
+ }
1096
+
1097
+ let tmpURL = '/api/media/video-frame/' + this._frameData.CacheKey + '/' + tmpFrame.Filename;
1098
+
1099
+ let tmpBody = document.getElementById('RetoldRemote-VEX-PreviewBody');
1100
+ if (tmpBody)
1101
+ {
1102
+ tmpBody.innerHTML = '<img src="' + tmpURL + '" alt="' + this._escapeHTML(tmpFrame.Label) + '">';
1103
+ }
1104
+
1105
+ let tmpTitle = document.getElementById('RetoldRemote-VEX-PreviewTitle');
1106
+ if (tmpTitle)
1107
+ {
1108
+ tmpTitle.textContent = tmpFrame.Label;
1109
+ }
1110
+
1111
+ // Also select the corresponding frame in the grid behind the overlay
1112
+ this._previewType = tmpFrame.Type;
1113
+ this._previewIndex = tmpFrame.Index;
1114
+
1115
+ if (tmpFrame.Type === 'regular')
1116
+ {
1117
+ this.selectFrame(tmpFrame.Index);
1118
+ }
1119
+ }
1120
+
1121
+ /**
1122
+ * Handle frame count dropdown change.
1123
+ *
1124
+ * @param {string} pValue - New frame count
1125
+ */
1126
+ onFrameCountChange(pValue)
1127
+ {
1128
+ this._frameCount = parseInt(pValue, 10) || 20;
1129
+ this.refresh();
1130
+ }
1131
+
1132
+ /**
1133
+ * Handle full-res checkbox change.
1134
+ *
1135
+ * @param {boolean} pChecked - Whether full res is enabled
1136
+ */
1137
+ onFullResChange(pChecked)
1138
+ {
1139
+ this._fullResFrames = pChecked;
1140
+ this.refresh();
1141
+ }
1142
+
1143
+ /**
1144
+ * Refresh (re-extract) frames with current settings.
1145
+ */
1146
+ refresh()
1147
+ {
1148
+ // Show loading state
1149
+ let tmpBody = document.getElementById('RetoldRemote-VEX-Body');
1150
+ if (tmpBody)
1151
+ {
1152
+ tmpBody.innerHTML = '<div class="retold-remote-vex-loading">'
1153
+ + '<div class="retold-remote-vex-loading-spinner"></div>'
1154
+ + 'Extracting frames from video...'
1155
+ + '</div>';
1156
+ }
1157
+
1158
+ // Hide timeline during loading
1159
+ let tmpTimeline = document.getElementById('RetoldRemote-VEX-Timeline');
1160
+ if (tmpTimeline)
1161
+ {
1162
+ tmpTimeline.style.display = 'none';
1163
+ }
1164
+
1165
+ this._selectedFrameIndex = -1;
1166
+ this._customFrames = [];
1167
+ this._fetchFrames(this._currentPath);
1168
+ }
1169
+
1170
+ /**
1171
+ * Navigate back to the video viewer.
1172
+ */
1173
+ goBack()
1174
+ {
1175
+ if (this._currentPath)
1176
+ {
1177
+ let tmpApp = this.pict.views['RetoldRemote-MediaViewer'];
1178
+ if (tmpApp)
1179
+ {
1180
+ tmpApp.showMedia(this._currentPath, 'video');
1181
+ }
1182
+ }
1183
+ else
1184
+ {
1185
+ let tmpNav = this.pict.providers['RetoldRemote-GalleryNavigation'];
1186
+ if (tmpNav)
1187
+ {
1188
+ tmpNav.closeViewer();
1189
+ }
1190
+ }
1191
+ }
1192
+
1193
+ /**
1194
+ * Show an error message.
1195
+ *
1196
+ * @param {string} pMessage - Error message
1197
+ */
1198
+ _showError(pMessage)
1199
+ {
1200
+ let tmpBody = document.getElementById('RetoldRemote-VEX-Body');
1201
+ if (tmpBody)
1202
+ {
1203
+ tmpBody.innerHTML = '<div class="retold-remote-vex-error">'
1204
+ + '<div class="retold-remote-vex-error-message">' + this._escapeHTML(pMessage || 'An error occurred.') + '</div>'
1205
+ + '<button class="retold-remote-vex-nav-btn" onclick="pict.views[\'RetoldRemote-VideoExplorer\'].goBack()">Back to Video</button>'
1206
+ + '</div>';
1207
+ }
1208
+ }
1209
+
1210
+ _escapeHTML(pText)
1211
+ {
1212
+ if (!pText) return '';
1213
+ return pText.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
1214
+ }
1215
+
1216
+ _formatFileSize(pBytes)
1217
+ {
1218
+ if (!pBytes || pBytes === 0) return '0 B';
1219
+ let tmpUnits = ['B', 'KB', 'MB', 'GB', 'TB'];
1220
+ let tmpIndex = Math.floor(Math.log(pBytes) / Math.log(1024));
1221
+ if (tmpIndex >= tmpUnits.length) tmpIndex = tmpUnits.length - 1;
1222
+ let tmpSize = pBytes / Math.pow(1024, tmpIndex);
1223
+ return tmpSize.toFixed(tmpIndex === 0 ? 0 : 1) + ' ' + tmpUnits[tmpIndex];
1224
+ }
1225
+ }
1226
+
1227
+ RetoldRemoteVideoExplorerView.default_configuration = _ViewConfiguration;
1228
+
1229
+ module.exports = RetoldRemoteVideoExplorerView;