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.
- package/html/index.html +2 -0
- package/package.json +20 -14
- package/source/Pict-Application-RetoldRemote.js +46 -5
- package/source/cli/RetoldRemote-CLI-Run.js +0 -0
- package/source/cli/RetoldRemote-Server-Setup.js +790 -8
- package/source/cli/commands/RetoldRemote-Command-Serve.js +34 -1
- package/source/providers/Pict-Provider-GalleryFilterSort.js +61 -9
- package/source/providers/Pict-Provider-GalleryNavigation.js +517 -18
- package/source/providers/Pict-Provider-RetoldRemote.js +11 -2
- package/source/providers/Pict-Provider-RetoldRemoteIcons.js +1 -0
- package/source/server/RetoldRemote-ArchiveService.js +830 -0
- package/source/server/RetoldRemote-AudioWaveformService.js +673 -0
- package/source/server/RetoldRemote-EbookService.js +242 -0
- package/source/server/RetoldRemote-MediaService.js +1 -1
- package/source/server/RetoldRemote-ToolDetector.js +31 -1
- package/source/server/RetoldRemote-VideoFrameService.js +486 -0
- package/source/views/PictView-Remote-AudioExplorer.js +1213 -0
- package/source/views/PictView-Remote-Gallery.js +141 -2
- package/source/views/PictView-Remote-Layout.js +18 -27
- package/source/views/PictView-Remote-MediaViewer.js +638 -39
- package/source/views/PictView-Remote-SettingsPanel.js +23 -0
- package/source/views/PictView-Remote-TopBar.js +121 -0
- package/source/views/PictView-Remote-VideoExplorer.js +1229 -0
- package/web-application/index.html +2 -0
- package/web-application/js/epub.min.js +1 -0
- package/web-application/retold-remote.js +7030 -1244
- package/web-application/retold-remote.js.map +1 -1
- package/web-application/retold-remote.min.js +13 -44
- package/web-application/retold-remote.min.js.map +1 -1
- package/web-application/retold-remote.compatible.js +0 -5764
- package/web-application/retold-remote.compatible.js.map +0 -1
- package/web-application/retold-remote.compatible.min.js +0 -120
- 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)">← Back</button>';
|
|
403
|
+
tmpHTML += '<div class="retold-remote-vex-title">Video Explorer — ' + 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 + '×' + 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
|
+
+ '×tamp=' + 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)">← Back</button>';
|
|
1002
|
+
tmpHTML += '<button class="retold-remote-vex-nav-btn" onclick="pict.views[\'RetoldRemote-VideoExplorer\'].previewPrevFrame()" title="Previous (\u2190)">‹ 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 ›</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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
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;
|