retold-remote 0.0.4 → 0.0.6

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 (63) hide show
  1. package/docs/README.md +181 -0
  2. package/docs/_cover.md +14 -0
  3. package/docs/_sidebar.md +10 -0
  4. package/docs/_topbar.md +3 -0
  5. package/docs/audio-viewer.md +133 -0
  6. package/docs/ebook-reader.md +90 -0
  7. package/docs/image-viewer.md +90 -0
  8. package/docs/server-setup.md +262 -0
  9. package/docs/video-viewer.md +134 -0
  10. package/html/docs.html +59 -0
  11. package/package.json +21 -7
  12. package/source/Pict-Application-RetoldRemote.js +143 -2
  13. package/source/RetoldRemote-ExtensionMaps.js +33 -0
  14. package/source/cli/RetoldRemote-Server-Setup.js +82 -67
  15. package/source/cli/commands/RetoldRemote-Command-Serve.js +5 -26
  16. package/source/providers/Pict-Provider-CollectionManager.js +934 -0
  17. package/source/providers/Pict-Provider-FormattingUtilities.js +109 -0
  18. package/source/providers/Pict-Provider-GalleryFilterSort.js +2 -11
  19. package/source/providers/Pict-Provider-GalleryNavigation.js +270 -353
  20. package/source/providers/Pict-Provider-RetoldRemoteIcons.js +52 -0
  21. package/source/providers/Pict-Provider-ToastNotification.js +96 -0
  22. package/source/providers/keyboard-handlers/KeyHandler-AudioExplorer.js +88 -0
  23. package/source/providers/keyboard-handlers/KeyHandler-Gallery.js +190 -0
  24. package/source/providers/keyboard-handlers/KeyHandler-Sidebar.js +65 -0
  25. package/source/providers/keyboard-handlers/KeyHandler-VideoExplorer.js +57 -0
  26. package/source/providers/keyboard-handlers/KeyHandler-Viewer.js +197 -0
  27. package/source/server/RetoldRemote-ArchiveService.js +2 -12
  28. package/source/server/RetoldRemote-AudioWaveformService.js +7 -16
  29. package/source/server/RetoldRemote-CollectionService.js +684 -0
  30. package/source/server/RetoldRemote-EbookService.js +7 -16
  31. package/source/server/RetoldRemote-MediaService.js +3 -14
  32. package/source/server/RetoldRemote-ParimeCache.js +349 -0
  33. package/source/server/RetoldRemote-ThumbnailCache.js +52 -20
  34. package/source/server/RetoldRemote-VideoFrameService.js +7 -15
  35. package/source/views/PictView-Remote-AudioExplorer.js +10 -43
  36. package/source/views/PictView-Remote-CollectionsPanel.js +1087 -0
  37. package/source/views/PictView-Remote-Gallery.js +237 -44
  38. package/source/views/PictView-Remote-ImageViewer.js +1 -34
  39. package/source/views/PictView-Remote-Layout.js +410 -20
  40. package/source/views/PictView-Remote-MediaViewer.js +338 -51
  41. package/source/views/PictView-Remote-SettingsPanel.js +155 -138
  42. package/source/views/PictView-Remote-TopBar.js +615 -14
  43. package/source/views/PictView-Remote-VLCSetup.js +766 -0
  44. package/source/views/PictView-Remote-VideoExplorer.js +20 -54
  45. package/web-application/css/docuserve.css +73 -0
  46. package/web-application/docs/README.md +181 -0
  47. package/web-application/docs/_cover.md +14 -0
  48. package/web-application/docs/_sidebar.md +10 -0
  49. package/web-application/docs/_topbar.md +3 -0
  50. package/web-application/docs/audio-viewer.md +133 -0
  51. package/web-application/docs/ebook-reader.md +90 -0
  52. package/web-application/docs/image-viewer.md +90 -0
  53. package/web-application/docs/server-setup.md +262 -0
  54. package/web-application/docs/video-viewer.md +134 -0
  55. package/web-application/docs.html +59 -0
  56. package/web-application/js/pict-docuserve.min.js +58 -0
  57. package/web-application/js/pict.min.js +2 -2
  58. package/web-application/js/pict.min.js.map +1 -1
  59. package/web-application/retold-remote.js +2558 -439
  60. package/web-application/retold-remote.js.map +1 -1
  61. package/web-application/retold-remote.min.js +41 -11
  62. package/web-application/retold-remote.min.js.map +1 -1
  63. package/server.js +0 -43
@@ -0,0 +1,934 @@
1
+ /**
2
+ * Retold Remote -- Collection Manager Provider
3
+ *
4
+ * Client-side state management and API communication for the
5
+ * collections feature. Provides methods for CRUD operations on
6
+ * collections and their items, plus panel state management.
7
+ *
8
+ * All collection state lives on pict.AppData.RetoldRemote and is
9
+ * mutated directly followed by explicit render calls -- matching the
10
+ * existing retold-remote state management pattern.
11
+ *
12
+ * @license MIT
13
+ */
14
+ const libPictProvider = require('pict-provider');
15
+
16
+ const _DefaultProviderConfiguration =
17
+ {
18
+ ProviderIdentifier: 'RetoldRemote-CollectionManager',
19
+ AutoInitialize: true,
20
+ AutoSolveWithApp: false
21
+ };
22
+
23
+ class CollectionManagerProvider extends libPictProvider
24
+ {
25
+ constructor(pFable, pOptions, pServiceHash)
26
+ {
27
+ super(pFable, pOptions, pServiceHash);
28
+ }
29
+
30
+ // -- State Accessors --------------------------------------------------
31
+
32
+ /**
33
+ * Shortcut to the RetoldRemote AppData namespace.
34
+ */
35
+ _getRemote()
36
+ {
37
+ return this.pict.AppData.RetoldRemote;
38
+ }
39
+
40
+ /**
41
+ * Get the collections panel view.
42
+ */
43
+ _getPanelView()
44
+ {
45
+ return this.pict.views['RetoldRemote-CollectionsPanel'];
46
+ }
47
+
48
+ /**
49
+ * Get the toast notification provider.
50
+ */
51
+ _getToast()
52
+ {
53
+ return this.pict.providers['RetoldRemote-ToastNotification'];
54
+ }
55
+
56
+ // -- API Methods ------------------------------------------------------
57
+
58
+ /**
59
+ * Fetch all collections (summaries) from the server.
60
+ *
61
+ * @param {Function} [fCallback] - Optional callback(pError, pCollections)
62
+ */
63
+ fetchCollections(fCallback)
64
+ {
65
+ let tmpSelf = this;
66
+ let tmpCallback = (typeof fCallback === 'function') ? fCallback : () => {};
67
+
68
+ fetch('/api/collections')
69
+ .then((pResponse) => pResponse.json())
70
+ .then((pData) =>
71
+ {
72
+ let tmpRemote = tmpSelf._getRemote();
73
+ tmpRemote.Collections = Array.isArray(pData) ? pData : [];
74
+
75
+ let tmpPanel = tmpSelf._getPanelView();
76
+ if (tmpPanel && tmpRemote.CollectionsPanelOpen)
77
+ {
78
+ tmpPanel.renderContent();
79
+ }
80
+
81
+ return tmpCallback(null, tmpRemote.Collections);
82
+ })
83
+ .catch((pError) =>
84
+ {
85
+ tmpSelf.log.error('Failed to fetch collections: ' + pError.message);
86
+ return tmpCallback(pError);
87
+ });
88
+ }
89
+
90
+ /**
91
+ * Fetch a single collection with all its items.
92
+ *
93
+ * @param {string} pGUID - Collection GUID
94
+ * @param {Function} [fCallback] - Optional callback(pError, pCollection)
95
+ */
96
+ fetchCollection(pGUID, fCallback)
97
+ {
98
+ let tmpSelf = this;
99
+ let tmpCallback = (typeof fCallback === 'function') ? fCallback : () => {};
100
+
101
+ fetch('/api/collections/' + encodeURIComponent(pGUID))
102
+ .then((pResponse) =>
103
+ {
104
+ if (!pResponse.ok)
105
+ {
106
+ throw new Error('Collection not found');
107
+ }
108
+ return pResponse.json();
109
+ })
110
+ .then((pData) =>
111
+ {
112
+ let tmpRemote = tmpSelf._getRemote();
113
+ tmpRemote.ActiveCollectionGUID = pGUID;
114
+ tmpRemote.ActiveCollection = pData;
115
+
116
+ let tmpPanel = tmpSelf._getPanelView();
117
+ if (tmpPanel)
118
+ {
119
+ tmpPanel.renderContent();
120
+ }
121
+
122
+ return tmpCallback(null, pData);
123
+ })
124
+ .catch((pError) =>
125
+ {
126
+ tmpSelf.log.error('Failed to fetch collection: ' + pError.message);
127
+ return tmpCallback(pError);
128
+ });
129
+ }
130
+
131
+ /**
132
+ * Create a new collection.
133
+ *
134
+ * @param {string} pName - Collection name
135
+ * @param {Function} [fCallback] - Optional callback(pError, pCollection)
136
+ */
137
+ createCollection(pName, fCallback)
138
+ {
139
+ let tmpSelf = this;
140
+ let tmpCallback = (typeof fCallback === 'function') ? fCallback : () => {};
141
+ let tmpGUID = this.fable.getUUID();
142
+
143
+ fetch('/api/collections/' + encodeURIComponent(tmpGUID),
144
+ {
145
+ method: 'PUT',
146
+ headers: { 'Content-Type': 'application/json' },
147
+ body: JSON.stringify({ Name: pName || 'Untitled Collection' })
148
+ })
149
+ .then((pResponse) => pResponse.json())
150
+ .then((pData) =>
151
+ {
152
+ let tmpRemote = tmpSelf._getRemote();
153
+ tmpRemote.LastUsedCollectionGUID = tmpGUID;
154
+
155
+ // Refresh the list
156
+ tmpSelf.fetchCollections();
157
+
158
+ let tmpToast = tmpSelf._getToast();
159
+ if (tmpToast)
160
+ {
161
+ tmpToast.show('Collection created: ' + (pData.Name || pName));
162
+ }
163
+
164
+ return tmpCallback(null, pData);
165
+ })
166
+ .catch((pError) =>
167
+ {
168
+ tmpSelf.log.error('Failed to create collection: ' + pError.message);
169
+ return tmpCallback(pError);
170
+ });
171
+ }
172
+
173
+ /**
174
+ * Update an existing collection's metadata.
175
+ *
176
+ * @param {object} pCollection - Collection object with GUID and updated fields
177
+ * @param {Function} [fCallback] - Optional callback(pError, pCollection)
178
+ */
179
+ updateCollection(pCollection, fCallback)
180
+ {
181
+ let tmpSelf = this;
182
+ let tmpCallback = (typeof fCallback === 'function') ? fCallback : () => {};
183
+
184
+ if (!pCollection || !pCollection.GUID)
185
+ {
186
+ return tmpCallback(new Error('Collection must have a GUID'));
187
+ }
188
+
189
+ fetch('/api/collections/' + encodeURIComponent(pCollection.GUID),
190
+ {
191
+ method: 'PUT',
192
+ headers: { 'Content-Type': 'application/json' },
193
+ body: JSON.stringify(pCollection)
194
+ })
195
+ .then((pResponse) => pResponse.json())
196
+ .then((pData) =>
197
+ {
198
+ let tmpRemote = tmpSelf._getRemote();
199
+
200
+ // Update in-memory active collection if it's the same one
201
+ if (tmpRemote.ActiveCollectionGUID === pCollection.GUID)
202
+ {
203
+ tmpRemote.ActiveCollection = pData;
204
+ }
205
+
206
+ // Refresh the list
207
+ tmpSelf.fetchCollections();
208
+
209
+ return tmpCallback(null, pData);
210
+ })
211
+ .catch((pError) =>
212
+ {
213
+ tmpSelf.log.error('Failed to update collection: ' + pError.message);
214
+ return tmpCallback(pError);
215
+ });
216
+ }
217
+
218
+ /**
219
+ * Delete a collection.
220
+ *
221
+ * @param {string} pGUID - Collection GUID
222
+ * @param {Function} [fCallback] - Optional callback(pError)
223
+ */
224
+ deleteCollection(pGUID, fCallback)
225
+ {
226
+ let tmpSelf = this;
227
+ let tmpCallback = (typeof fCallback === 'function') ? fCallback : () => {};
228
+
229
+ fetch('/api/collections/' + encodeURIComponent(pGUID),
230
+ {
231
+ method: 'DELETE'
232
+ })
233
+ .then((pResponse) => pResponse.json())
234
+ .then(() =>
235
+ {
236
+ let tmpRemote = tmpSelf._getRemote();
237
+
238
+ // If we deleted the active collection, go back to list
239
+ if (tmpRemote.ActiveCollectionGUID === pGUID)
240
+ {
241
+ tmpRemote.ActiveCollectionGUID = null;
242
+ tmpRemote.ActiveCollection = null;
243
+ tmpRemote.CollectionsPanelMode = 'list';
244
+ }
245
+
246
+ // If we deleted the last-used collection, clear it
247
+ if (tmpRemote.LastUsedCollectionGUID === pGUID)
248
+ {
249
+ tmpRemote.LastUsedCollectionGUID = null;
250
+ }
251
+
252
+ // Refresh the list
253
+ tmpSelf.fetchCollections();
254
+
255
+ let tmpToast = tmpSelf._getToast();
256
+ if (tmpToast)
257
+ {
258
+ tmpToast.show('Collection deleted');
259
+ }
260
+
261
+ return tmpCallback(null);
262
+ })
263
+ .catch((pError) =>
264
+ {
265
+ tmpSelf.log.error('Failed to delete collection: ' + pError.message);
266
+ return tmpCallback(pError);
267
+ });
268
+ }
269
+
270
+ /**
271
+ * Add item(s) to a collection.
272
+ *
273
+ * @param {string} pGUID - Collection GUID
274
+ * @param {Array} pItems - Array of item objects (each with Type, Path, etc.)
275
+ * @param {Function} [fCallback] - Optional callback(pError, pCollection)
276
+ */
277
+ addItemsToCollection(pGUID, pItems, fCallback)
278
+ {
279
+ let tmpSelf = this;
280
+ let tmpCallback = (typeof fCallback === 'function') ? fCallback : () => {};
281
+
282
+ fetch('/api/collections/' + encodeURIComponent(pGUID) + '/items',
283
+ {
284
+ method: 'POST',
285
+ headers: { 'Content-Type': 'application/json' },
286
+ body: JSON.stringify({ Items: pItems })
287
+ })
288
+ .then((pResponse) => pResponse.json())
289
+ .then((pData) =>
290
+ {
291
+ let tmpRemote = tmpSelf._getRemote();
292
+ tmpRemote.LastUsedCollectionGUID = pGUID;
293
+
294
+ // Update in-memory active collection if it's the same one
295
+ if (tmpRemote.ActiveCollectionGUID === pGUID)
296
+ {
297
+ tmpRemote.ActiveCollection = pData;
298
+ }
299
+
300
+ // Refresh the summary list
301
+ tmpSelf.fetchCollections();
302
+
303
+ let tmpPanel = tmpSelf._getPanelView();
304
+ if (tmpPanel && tmpRemote.CollectionsPanelMode === 'detail' && tmpRemote.ActiveCollectionGUID === pGUID)
305
+ {
306
+ tmpPanel.renderContent();
307
+ }
308
+
309
+ let tmpToast = tmpSelf._getToast();
310
+ if (tmpToast)
311
+ {
312
+ let tmpCollectionName = pData.Name || 'collection';
313
+ tmpToast.show('Added ' + pItems.length + ' item' + (pItems.length > 1 ? 's' : '') + ' to ' + tmpCollectionName);
314
+ }
315
+
316
+ return tmpCallback(null, pData);
317
+ })
318
+ .catch((pError) =>
319
+ {
320
+ tmpSelf.log.error('Failed to add items to collection: ' + pError.message);
321
+ return tmpCallback(pError);
322
+ });
323
+ }
324
+
325
+ /**
326
+ * Remove an item from a collection.
327
+ *
328
+ * @param {string} pGUID - Collection GUID
329
+ * @param {string} pItemID - Item ID within the collection
330
+ * @param {Function} [fCallback] - Optional callback(pError, pCollection)
331
+ */
332
+ removeItemFromCollection(pGUID, pItemID, fCallback)
333
+ {
334
+ let tmpSelf = this;
335
+ let tmpCallback = (typeof fCallback === 'function') ? fCallback : () => {};
336
+
337
+ fetch('/api/collections/' + encodeURIComponent(pGUID) + '/items/' + encodeURIComponent(pItemID),
338
+ {
339
+ method: 'DELETE'
340
+ })
341
+ .then((pResponse) => pResponse.json())
342
+ .then((pData) =>
343
+ {
344
+ let tmpRemote = tmpSelf._getRemote();
345
+
346
+ // Update in-memory active collection if it's the same one
347
+ if (tmpRemote.ActiveCollectionGUID === pGUID)
348
+ {
349
+ tmpRemote.ActiveCollection = pData;
350
+ }
351
+
352
+ // Refresh the summary list
353
+ tmpSelf.fetchCollections();
354
+
355
+ let tmpPanel = tmpSelf._getPanelView();
356
+ if (tmpPanel && tmpRemote.CollectionsPanelMode === 'detail' && tmpRemote.ActiveCollectionGUID === pGUID)
357
+ {
358
+ tmpPanel.renderContent();
359
+ }
360
+
361
+ return tmpCallback(null, pData);
362
+ })
363
+ .catch((pError) =>
364
+ {
365
+ tmpSelf.log.error('Failed to remove item from collection: ' + pError.message);
366
+ return tmpCallback(pError);
367
+ });
368
+ }
369
+
370
+ /**
371
+ * Reorder items in a collection (manual sort).
372
+ *
373
+ * @param {string} pGUID - Collection GUID
374
+ * @param {Array} pItemOrder - Array of item IDs in desired order
375
+ * @param {Function} [fCallback] - Optional callback(pError, pCollection)
376
+ */
377
+ reorderItems(pGUID, pItemOrder, fCallback)
378
+ {
379
+ let tmpSelf = this;
380
+ let tmpCallback = (typeof fCallback === 'function') ? fCallback : () => {};
381
+
382
+ fetch('/api/collections/' + encodeURIComponent(pGUID) + '/reorder',
383
+ {
384
+ method: 'PUT',
385
+ headers: { 'Content-Type': 'application/json' },
386
+ body: JSON.stringify({ ItemOrder: pItemOrder })
387
+ })
388
+ .then((pResponse) => pResponse.json())
389
+ .then((pData) =>
390
+ {
391
+ let tmpRemote = tmpSelf._getRemote();
392
+
393
+ if (tmpRemote.ActiveCollectionGUID === pGUID)
394
+ {
395
+ tmpRemote.ActiveCollection = pData;
396
+ }
397
+
398
+ let tmpPanel = tmpSelf._getPanelView();
399
+ if (tmpPanel && tmpRemote.CollectionsPanelMode === 'detail')
400
+ {
401
+ tmpPanel.renderContent();
402
+ }
403
+
404
+ return tmpCallback(null, pData);
405
+ })
406
+ .catch((pError) =>
407
+ {
408
+ tmpSelf.log.error('Failed to reorder items: ' + pError.message);
409
+ return tmpCallback(pError);
410
+ });
411
+ }
412
+
413
+ /**
414
+ * Copy items from one collection to another.
415
+ *
416
+ * @param {string} pSourceGUID - Source collection GUID
417
+ * @param {string} pTargetGUID - Target collection GUID
418
+ * @param {Array} pItemIDs - Array of item IDs to copy
419
+ * @param {Function} [fCallback] - Optional callback(pError, pTargetCollection)
420
+ */
421
+ copyItems(pSourceGUID, pTargetGUID, pItemIDs, fCallback)
422
+ {
423
+ let tmpSelf = this;
424
+ let tmpCallback = (typeof fCallback === 'function') ? fCallback : () => {};
425
+
426
+ fetch('/api/collections/copy-items',
427
+ {
428
+ method: 'POST',
429
+ headers: { 'Content-Type': 'application/json' },
430
+ body: JSON.stringify(
431
+ {
432
+ SourceGUID: pSourceGUID,
433
+ TargetGUID: pTargetGUID,
434
+ ItemIDs: pItemIDs
435
+ })
436
+ })
437
+ .then((pResponse) => pResponse.json())
438
+ .then((pData) =>
439
+ {
440
+ // Refresh the list
441
+ tmpSelf.fetchCollections();
442
+
443
+ let tmpToast = tmpSelf._getToast();
444
+ if (tmpToast)
445
+ {
446
+ tmpToast.show('Copied ' + pItemIDs.length + ' item' + (pItemIDs.length > 1 ? 's' : ''));
447
+ }
448
+
449
+ return tmpCallback(null, pData);
450
+ })
451
+ .catch((pError) =>
452
+ {
453
+ tmpSelf.log.error('Failed to copy items: ' + pError.message);
454
+ return tmpCallback(pError);
455
+ });
456
+ }
457
+
458
+ // -- Panel State Methods ----------------------------------------------
459
+
460
+ /**
461
+ * Toggle the collections panel open/closed.
462
+ */
463
+ togglePanel()
464
+ {
465
+ let tmpRemote = this._getRemote();
466
+ tmpRemote.CollectionsPanelOpen = !tmpRemote.CollectionsPanelOpen;
467
+
468
+ let tmpWrap = document.getElementById('RetoldRemote-Collections-Wrap');
469
+ if (tmpWrap)
470
+ {
471
+ if (tmpRemote.CollectionsPanelOpen)
472
+ {
473
+ tmpWrap.classList.remove('collapsed');
474
+ // Restore saved width
475
+ if (tmpRemote.CollectionsPanelWidth)
476
+ {
477
+ tmpWrap.style.width = tmpRemote.CollectionsPanelWidth + 'px';
478
+ }
479
+ // Fetch latest collections when opening
480
+ this.fetchCollections();
481
+ }
482
+ else
483
+ {
484
+ tmpWrap.classList.add('collapsed');
485
+ }
486
+ }
487
+
488
+ // Update topbar button state
489
+ let tmpTopBar = this.pict.views['ContentEditor-TopBar'];
490
+ if (tmpTopBar && typeof tmpTopBar.updateCollectionsIcon === 'function')
491
+ {
492
+ tmpTopBar.updateCollectionsIcon();
493
+ }
494
+
495
+ // Persist setting
496
+ this.pict.PictApplication.saveSettings();
497
+
498
+ // Recalculate gallery columns after panel animation
499
+ let tmpSelf = this;
500
+ setTimeout(() =>
501
+ {
502
+ let tmpGalleryNav = tmpSelf.pict.providers['RetoldRemote-GalleryNavigation'];
503
+ if (tmpGalleryNav && typeof tmpGalleryNav.recalculateColumns === 'function')
504
+ {
505
+ tmpGalleryNav.recalculateColumns();
506
+ }
507
+ }, 250);
508
+ }
509
+
510
+ /**
511
+ * Open the collections panel.
512
+ */
513
+ openPanel()
514
+ {
515
+ let tmpRemote = this._getRemote();
516
+ if (!tmpRemote.CollectionsPanelOpen)
517
+ {
518
+ this.togglePanel();
519
+ }
520
+ }
521
+
522
+ /**
523
+ * Close the collections panel.
524
+ */
525
+ closePanel()
526
+ {
527
+ let tmpRemote = this._getRemote();
528
+ if (tmpRemote.CollectionsPanelOpen)
529
+ {
530
+ this.togglePanel();
531
+ }
532
+ }
533
+
534
+ // -- Convenience Methods ----------------------------------------------
535
+
536
+ /**
537
+ * Add the currently viewed file to a collection.
538
+ * If pGUID is not provided, uses the last-used collection.
539
+ *
540
+ * @param {string} [pGUID] - Collection GUID (omit for quick-add to last-used)
541
+ * @returns {boolean} true if the add was initiated
542
+ */
543
+ addCurrentFileToCollection(pGUID)
544
+ {
545
+ let tmpRemote = this._getRemote();
546
+ let tmpFilePath = this.pict.AppData.ContentEditor.CurrentFile;
547
+
548
+ if (!tmpFilePath)
549
+ {
550
+ return false;
551
+ }
552
+
553
+ let tmpTargetGUID = pGUID || tmpRemote.LastUsedCollectionGUID;
554
+ if (!tmpTargetGUID)
555
+ {
556
+ return false;
557
+ }
558
+
559
+ // Build the item — detect archive subfiles and video timestamp context
560
+ let tmpItem =
561
+ {
562
+ Type: 'file',
563
+ Path: tmpFilePath,
564
+ Label: '',
565
+ Note: ''
566
+ };
567
+
568
+ // Detect archive subfile — path contains an archive extension followed by /
569
+ let tmpArchiveMatch = tmpFilePath.match(/^(.*?\.(zip|7z|rar|tar|tgz|cbz|cbr|tar\.gz|tar\.bz2|tar\.xz))\/(.*)/i);
570
+ if (tmpArchiveMatch)
571
+ {
572
+ tmpItem.Type = 'subfile';
573
+ tmpItem.ArchivePath = tmpArchiveMatch[1];
574
+ }
575
+
576
+ // If we're viewing a video with the player active, capture current timestamp as video-frame
577
+ if (tmpRemote.ActiveMode === 'viewer' && tmpRemote.CurrentViewerMediaType === 'video' && !tmpRemote.VideoMenuActive)
578
+ {
579
+ let tmpVideo = document.getElementById('RetoldRemote-VideoPlayer');
580
+ if (tmpVideo && !isNaN(tmpVideo.currentTime) && tmpVideo.currentTime > 0)
581
+ {
582
+ tmpItem.Type = 'video-frame';
583
+ tmpItem.FrameTimestamp = Math.round(tmpVideo.currentTime * 100) / 100;
584
+ }
585
+ }
586
+
587
+ // If we have a hash for this file, include it
588
+ let tmpProvider = this.pict.providers['RetoldRemote-Provider'];
589
+ if (tmpProvider)
590
+ {
591
+ let tmpHash = tmpProvider.getHashForPath(tmpFilePath);
592
+ if (tmpHash)
593
+ {
594
+ tmpItem.Hash = tmpHash;
595
+ }
596
+ }
597
+
598
+ this.addItemsToCollection(tmpTargetGUID, [tmpItem]);
599
+ return true;
600
+ }
601
+
602
+ /**
603
+ * Add a video frame from the video explorer to a collection.
604
+ *
605
+ * @param {string} pGUID - Collection GUID
606
+ * @returns {boolean} true if the add was initiated
607
+ */
608
+ addVideoFrameToCollection(pGUID)
609
+ {
610
+ let tmpRemote = this._getRemote();
611
+ let tmpTargetGUID = pGUID || tmpRemote.LastUsedCollectionGUID;
612
+ if (!tmpTargetGUID)
613
+ {
614
+ return false;
615
+ }
616
+
617
+ let tmpVEX = this.pict.views['RetoldRemote-VideoExplorer'];
618
+ if (!tmpVEX || !tmpVEX._currentPath || !tmpVEX._frameData)
619
+ {
620
+ return false;
621
+ }
622
+
623
+ let tmpFrameIndex = tmpVEX._selectedFrameIndex;
624
+ if (tmpFrameIndex < 0)
625
+ {
626
+ // No frame selected — use first frame
627
+ tmpFrameIndex = 0;
628
+ }
629
+
630
+ let tmpFrame = tmpVEX._frameData.Frames[tmpFrameIndex];
631
+ if (!tmpFrame)
632
+ {
633
+ return false;
634
+ }
635
+
636
+ let tmpItem =
637
+ {
638
+ Type: 'video-frame',
639
+ Path: tmpVEX._currentPath,
640
+ FrameTimestamp: tmpFrame.Timestamp,
641
+ Label: tmpFrame.TimestampFormatted || '',
642
+ Note: ''
643
+ };
644
+
645
+ this.addItemsToCollection(tmpTargetGUID, [tmpItem]);
646
+ return true;
647
+ }
648
+
649
+ /**
650
+ * Add a video clip (time range) to a collection from the video player.
651
+ *
652
+ * Uses the current playback position and a duration offset, or explicit
653
+ * start/end timestamps.
654
+ *
655
+ * @param {string} pGUID - Collection GUID
656
+ * @param {number} pStartTime - Start time in seconds
657
+ * @param {number} pEndTime - End time in seconds
658
+ * @returns {boolean} true if the add was initiated
659
+ */
660
+ addVideoClipToCollection(pGUID, pStartTime, pEndTime)
661
+ {
662
+ let tmpRemote = this._getRemote();
663
+ let tmpTargetGUID = pGUID || tmpRemote.LastUsedCollectionGUID;
664
+ if (!tmpTargetGUID)
665
+ {
666
+ return false;
667
+ }
668
+
669
+ let tmpFilePath = tmpRemote.CurrentViewerFile;
670
+ if (!tmpFilePath)
671
+ {
672
+ return false;
673
+ }
674
+
675
+ let tmpItem =
676
+ {
677
+ Type: 'video-clip',
678
+ Path: tmpFilePath,
679
+ VideoStart: pStartTime,
680
+ VideoEnd: pEndTime,
681
+ Label: this._formatTimestamp(pStartTime) + ' - ' + this._formatTimestamp(pEndTime),
682
+ Note: ''
683
+ };
684
+
685
+ this.addItemsToCollection(tmpTargetGUID, [tmpItem]);
686
+ return true;
687
+ }
688
+
689
+ /**
690
+ * Add an audio snippet (selected range) from the audio explorer.
691
+ *
692
+ * @param {string} pGUID - Collection GUID
693
+ * @returns {boolean} true if the add was initiated
694
+ */
695
+ addAudioSnippetToCollection(pGUID)
696
+ {
697
+ let tmpRemote = this._getRemote();
698
+ let tmpTargetGUID = pGUID || tmpRemote.LastUsedCollectionGUID;
699
+ if (!tmpTargetGUID)
700
+ {
701
+ return false;
702
+ }
703
+
704
+ let tmpAEX = this.pict.views['RetoldRemote-AudioExplorer'];
705
+ if (!tmpAEX || !tmpAEX._currentPath || !tmpAEX._waveformData)
706
+ {
707
+ return false;
708
+ }
709
+
710
+ let tmpDuration = tmpAEX._waveformData.Duration || 0;
711
+ if (tmpDuration <= 0)
712
+ {
713
+ return false;
714
+ }
715
+
716
+ // Convert normalized selection (0..1) to seconds
717
+ let tmpStart = 0;
718
+ let tmpEnd = tmpDuration;
719
+ if (tmpAEX._selectionStart >= 0 && tmpAEX._selectionEnd >= 0)
720
+ {
721
+ tmpStart = Math.round(tmpAEX._selectionStart * tmpDuration * 100) / 100;
722
+ tmpEnd = Math.round(tmpAEX._selectionEnd * tmpDuration * 100) / 100;
723
+ }
724
+
725
+ let tmpItem =
726
+ {
727
+ Type: 'video-clip', // reuse video-clip type for audio time ranges
728
+ Path: tmpAEX._currentPath,
729
+ VideoStart: tmpStart,
730
+ VideoEnd: tmpEnd,
731
+ Label: this._formatTimestamp(tmpStart) + ' - ' + this._formatTimestamp(tmpEnd),
732
+ Note: ''
733
+ };
734
+
735
+ this.addItemsToCollection(tmpTargetGUID, [tmpItem]);
736
+ return true;
737
+ }
738
+
739
+ /**
740
+ * Format a timestamp in seconds to a human-readable string.
741
+ *
742
+ * @param {number} pSeconds - Timestamp in seconds
743
+ * @returns {string} Formatted string like "1:23" or "1:01:23"
744
+ */
745
+ _formatTimestamp(pSeconds)
746
+ {
747
+ if (typeof pSeconds !== 'number' || isNaN(pSeconds))
748
+ {
749
+ return '0:00';
750
+ }
751
+
752
+ let tmpTotalSeconds = Math.floor(pSeconds);
753
+ let tmpHours = Math.floor(tmpTotalSeconds / 3600);
754
+ let tmpMinutes = Math.floor((tmpTotalSeconds % 3600) / 60);
755
+ let tmpSecs = tmpTotalSeconds % 60;
756
+
757
+ if (tmpHours > 0)
758
+ {
759
+ return tmpHours + ':' + (tmpMinutes < 10 ? '0' : '') + tmpMinutes + ':' + (tmpSecs < 10 ? '0' : '') + tmpSecs;
760
+ }
761
+ return tmpMinutes + ':' + (tmpSecs < 10 ? '0' : '') + tmpSecs;
762
+ }
763
+
764
+ /**
765
+ * Add the currently browsed folder to a collection.
766
+ *
767
+ * @param {string} pGUID - Collection GUID
768
+ * @param {string} pMode - "folder" (add folder reference) or "contents" (add folder contents wildcard)
769
+ */
770
+ addCurrentFolderToCollection(pGUID, pMode)
771
+ {
772
+ let tmpRemote = this._getRemote();
773
+ let tmpCurrentPath = (this.pict.AppData.PictFileBrowser && this.pict.AppData.PictFileBrowser.CurrentLocation) || '';
774
+
775
+ if (!tmpCurrentPath || !pGUID)
776
+ {
777
+ return;
778
+ }
779
+
780
+ let tmpItem =
781
+ {
782
+ Type: (pMode === 'contents') ? 'folder-contents' : 'folder',
783
+ Path: tmpCurrentPath,
784
+ Label: '',
785
+ Note: ''
786
+ };
787
+
788
+ this.addItemsToCollection(pGUID, [tmpItem]);
789
+ }
790
+
791
+ /**
792
+ * Sort a collection's Items array in place (client-side mirror of server logic).
793
+ *
794
+ * @param {Array} pItems - Items array
795
+ * @param {string} pSortMode - "manual" | "name" | "modified" | "type"
796
+ * @param {string} pSortDirection - "asc" | "desc"
797
+ * @returns {Array} The same array, sorted
798
+ */
799
+ _sortItems(pItems, pSortMode, pSortDirection)
800
+ {
801
+ if (!Array.isArray(pItems) || pItems.length < 2)
802
+ {
803
+ return pItems;
804
+ }
805
+
806
+ let tmpDirection = (pSortDirection === 'desc') ? -1 : 1;
807
+
808
+ switch (pSortMode)
809
+ {
810
+ case 'name':
811
+ pItems.sort((a, b) =>
812
+ {
813
+ let tmpA = (a.Label || a.Path || '').toLowerCase();
814
+ let tmpB = (b.Label || b.Path || '').toLowerCase();
815
+ // Sort by filename portion only (after last /)
816
+ let tmpSlashA = tmpA.lastIndexOf('/');
817
+ let tmpSlashB = tmpB.lastIndexOf('/');
818
+ if (tmpSlashA >= 0) tmpA = tmpA.substring(tmpSlashA + 1);
819
+ if (tmpSlashB >= 0) tmpB = tmpB.substring(tmpSlashB + 1);
820
+ return tmpDirection * tmpA.localeCompare(tmpB);
821
+ });
822
+ break;
823
+
824
+ case 'type':
825
+ pItems.sort((a, b) =>
826
+ {
827
+ let tmpA = (a.Type || '').toLowerCase();
828
+ let tmpB = (b.Type || '').toLowerCase();
829
+ return tmpDirection * tmpA.localeCompare(tmpB);
830
+ });
831
+ break;
832
+
833
+ case 'modified':
834
+ pItems.sort((a, b) =>
835
+ {
836
+ let tmpA = a.AddedAt || '';
837
+ let tmpB = b.AddedAt || '';
838
+ return tmpDirection * tmpA.localeCompare(tmpB);
839
+ });
840
+ break;
841
+
842
+ case 'manual':
843
+ default:
844
+ pItems.sort((a, b) =>
845
+ {
846
+ return tmpDirection * ((a.SortOrder || 0) - (b.SortOrder || 0));
847
+ });
848
+ break;
849
+ }
850
+
851
+ return pItems;
852
+ }
853
+
854
+ /**
855
+ * Sort the active collection's items in place and re-render the panel.
856
+ * Saves the sort preference to the server in the background.
857
+ *
858
+ * @param {string} pSortMode - Sort mode (or null to keep current)
859
+ * @param {string} pSortDirection - Sort direction (or null to keep current)
860
+ */
861
+ sortActiveCollection(pSortMode, pSortDirection)
862
+ {
863
+ let tmpRemote = this._getRemote();
864
+ let tmpCollection = tmpRemote.ActiveCollection;
865
+
866
+ if (!tmpCollection)
867
+ {
868
+ return;
869
+ }
870
+
871
+ // Update sort preferences
872
+ if (typeof pSortMode === 'string')
873
+ {
874
+ tmpCollection.SortMode = pSortMode;
875
+ }
876
+ if (typeof pSortDirection === 'string')
877
+ {
878
+ tmpCollection.SortDirection = pSortDirection;
879
+ }
880
+
881
+ // Sort items locally
882
+ this._sortItems(tmpCollection.Items || [], tmpCollection.SortMode, tmpCollection.SortDirection);
883
+
884
+ // Re-render the panel immediately
885
+ let tmpPanel = this._getPanelView();
886
+ if (tmpPanel)
887
+ {
888
+ tmpPanel.renderContent();
889
+ }
890
+
891
+ // Save sort preference to server in background (no re-render needed)
892
+ fetch('/api/collections/' + encodeURIComponent(tmpCollection.GUID),
893
+ {
894
+ method: 'PUT',
895
+ headers: { 'Content-Type': 'application/json' },
896
+ body: JSON.stringify({ GUID: tmpCollection.GUID, SortMode: tmpCollection.SortMode, SortDirection: tmpCollection.SortDirection })
897
+ })
898
+ .catch((pError) =>
899
+ {
900
+ this.log.error('Failed to save sort preference: ' + pError.message);
901
+ });
902
+ }
903
+
904
+ /**
905
+ * Search the loaded collections by name/description/tags (client-side filter).
906
+ *
907
+ * @param {string} pQuery - Search query
908
+ * @returns {Array} Filtered collection summaries
909
+ */
910
+ searchCollections(pQuery)
911
+ {
912
+ let tmpRemote = this._getRemote();
913
+ let tmpQuery = (pQuery || '').toLowerCase();
914
+
915
+ if (!tmpQuery)
916
+ {
917
+ return tmpRemote.Collections;
918
+ }
919
+
920
+ return tmpRemote.Collections.filter((pCollection) =>
921
+ {
922
+ let tmpName = (pCollection.Name || '').toLowerCase();
923
+ let tmpDesc = (pCollection.Description || '').toLowerCase();
924
+ let tmpTags = (pCollection.Tags || []).join(' ').toLowerCase();
925
+ return tmpName.indexOf(tmpQuery) >= 0 ||
926
+ tmpDesc.indexOf(tmpQuery) >= 0 ||
927
+ tmpTags.indexOf(tmpQuery) >= 0;
928
+ });
929
+ }
930
+ }
931
+
932
+ CollectionManagerProvider.default_configuration = _DefaultProviderConfiguration;
933
+
934
+ module.exports = CollectionManagerProvider;