retold-remote 0.0.23 → 0.0.26

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 (79) hide show
  1. package/css/retold-remote.css +343 -20
  2. package/docs/.nojekyll +0 -0
  3. package/docs/README.md +64 -12
  4. package/docs/_cover.md +6 -6
  5. package/docs/_sidebar.md +2 -0
  6. package/docs/_topbar.md +1 -1
  7. package/docs/_version.json +7 -0
  8. package/docs/collections.md +30 -0
  9. package/docs/css/docuserve.css +327 -0
  10. package/docs/ebook-reader.md +75 -1
  11. package/docs/image-explorer.md +62 -2
  12. package/docs/index.html +39 -0
  13. package/docs/retold-catalog.json +254 -0
  14. package/docs/retold-keyword-index.json +31216 -0
  15. package/docs/server-setup.md +122 -91
  16. package/docs/stack-launcher.md +218 -0
  17. package/docs/synology.md +585 -0
  18. package/docs/ultravisor-configuration.md +5 -5
  19. package/docs/ultravisor-integration.md +4 -2
  20. package/package.json +20 -14
  21. package/source/Pict-Application-RetoldRemote.js +22 -0
  22. package/source/RetoldRemote-ExtensionMaps.js +1 -1
  23. package/source/cli/RetoldRemote-Server-Setup.js +460 -7
  24. package/source/cli/RetoldRemote-Stack-Launcher.js +563 -0
  25. package/source/cli/RetoldRemote-Stack-Run.js +41 -0
  26. package/source/cli/commands/RetoldRemote-Command-Serve.js +129 -54
  27. package/source/providers/CollectionManager-AddItems.js +166 -0
  28. package/source/providers/Pict-Provider-GalleryNavigation.js +55 -0
  29. package/source/providers/Pict-Provider-OperationStatus.js +597 -0
  30. package/source/providers/keyboard-handlers/KeyHandler-ImageExplorer.js +20 -1
  31. package/source/providers/keyboard-handlers/KeyHandler-Viewer.js +23 -0
  32. package/source/server/RetoldRemote-AudioWaveformService.js +49 -3
  33. package/source/server/RetoldRemote-CollectionExportService.js +763 -0
  34. package/source/server/RetoldRemote-CollectionService.js +5 -0
  35. package/source/server/RetoldRemote-EbookService.js +218 -3
  36. package/source/server/RetoldRemote-ImageService.js +221 -46
  37. package/source/server/RetoldRemote-MediaService.js +63 -4
  38. package/source/server/RetoldRemote-MetadataCache.js +25 -5
  39. package/source/server/RetoldRemote-OperationBroadcaster.js +363 -0
  40. package/source/server/RetoldRemote-SubimageService.js +680 -0
  41. package/source/server/RetoldRemote-ToolDetector.js +50 -0
  42. package/source/server/RetoldRemote-UltravisorBeacon.js +18 -3
  43. package/source/server/RetoldRemote-UltravisorDispatcher.js +65 -491
  44. package/source/server/RetoldRemote-UltravisorOperations.js +133 -20
  45. package/source/server/RetoldRemote-VideoFrameService.js +302 -9
  46. package/source/views/MediaViewer-EbookViewer.js +419 -1
  47. package/source/views/MediaViewer-PdfViewer.js +1050 -0
  48. package/source/views/PictView-Remote-AudioExplorer.js +77 -1
  49. package/source/views/PictView-Remote-CollectionsPanel.js +213 -0
  50. package/source/views/PictView-Remote-Gallery.js +365 -64
  51. package/source/views/PictView-Remote-ImageExplorer.js +1529 -44
  52. package/source/views/PictView-Remote-ImageViewer.js +2 -2
  53. package/source/views/PictView-Remote-Layout.js +58 -0
  54. package/source/views/PictView-Remote-MediaViewer.js +100 -25
  55. package/source/views/PictView-Remote-RegionsBrowser.js +554 -0
  56. package/source/views/PictView-Remote-SubimagesPanel.js +353 -0
  57. package/source/views/PictView-Remote-TopBar.js +1 -0
  58. package/source/views/PictView-Remote-VideoExplorer.js +77 -1
  59. package/web-application/css/docuserve.css +277 -23
  60. package/web-application/css/retold-remote.css +343 -20
  61. package/web-application/docs/README.md +64 -12
  62. package/web-application/docs/_cover.md +6 -6
  63. package/web-application/docs/_sidebar.md +2 -0
  64. package/web-application/docs/_topbar.md +1 -1
  65. package/web-application/docs/collections.md +30 -0
  66. package/web-application/docs/ebook-reader.md +75 -1
  67. package/web-application/docs/image-explorer.md +62 -2
  68. package/web-application/docs/server-setup.md +122 -91
  69. package/web-application/docs/stack-launcher.md +218 -0
  70. package/web-application/docs/synology.md +585 -0
  71. package/web-application/docs/ultravisor-configuration.md +5 -5
  72. package/web-application/docs/ultravisor-integration.md +4 -2
  73. package/web-application/js/pict-docuserve.min.js +12 -12
  74. package/web-application/js/pict.min.js +2 -2
  75. package/web-application/js/pict.min.js.map +1 -1
  76. package/web-application/retold-remote.js +6596 -1784
  77. package/web-application/retold-remote.js.map +1 -1
  78. package/web-application/retold-remote.min.js +75 -23
  79. package/web-application/retold-remote.min.js.map +1 -1
@@ -15,6 +15,14 @@ const _ViewConfiguration =
15
15
  Renderables: []
16
16
  };
17
17
 
18
+ // Chunked rendering tuning constants
19
+ const _CHUNKED_RENDER_THRESHOLD = 500; // Below this, render synchronously in one shot
20
+ const _CHUNK_FIRST_SIZE = 250; // First chunk — appears within one frame
21
+ const _CHUNK_SUBSEQUENT_SIZE = 500; // Later chunks — larger for throughput
22
+
23
+ // Thumbnail concurrency cap
24
+ const _MAX_THUMBNAIL_CONCURRENCY = 8;
25
+
18
26
  class RetoldRemoteGalleryView extends libPictView
19
27
  {
20
28
  constructor(pFable, pOptions, pServiceHash)
@@ -22,6 +30,59 @@ class RetoldRemoteGalleryView extends libPictView
22
30
  super(pFable, pOptions, pServiceHash);
23
31
 
24
32
  this._intersectionObserver = null;
33
+
34
+ // Chunked render state
35
+ this._activeRenderFrame = null; // requestAnimationFrame id
36
+ this._activeRenderToken = 0; // incremented to invalidate in-flight chunked renders
37
+
38
+ // Thumbnail loading queue
39
+ this._thumbnailQueue = [];
40
+ this._thumbnailInFlight = 0;
41
+ }
42
+
43
+ /**
44
+ * Paint an immediate loading overlay in the gallery container.
45
+ * Called by the application's loadFileList() before the fetch starts
46
+ * so the user sees instant feedback when they click a folder.
47
+ *
48
+ * @param {string} pPath - Path being loaded (for display)
49
+ */
50
+ showLoadingState(pPath)
51
+ {
52
+ let tmpContainer = document.getElementById('RetoldRemote-Gallery-Container');
53
+ if (!tmpContainer)
54
+ {
55
+ return;
56
+ }
57
+
58
+ let tmpFmt = this.pict.providers['RetoldRemote-FormattingUtilities'];
59
+ let tmpEscaped = tmpFmt ? tmpFmt.escapeHTML(pPath || '') : (pPath || '');
60
+
61
+ let tmpHTML = '<div class="retold-remote-gallery-loading">';
62
+ tmpHTML += '<div class="retold-remote-gallery-loading-spinner"></div>';
63
+ tmpHTML += '<div class="retold-remote-gallery-loading-text">Loading folder\u2026</div>';
64
+ if (tmpEscaped)
65
+ {
66
+ tmpHTML += '<div class="retold-remote-gallery-loading-path">' + tmpEscaped + '</div>';
67
+ }
68
+ tmpHTML += '</div>';
69
+
70
+ tmpContainer.innerHTML = tmpHTML;
71
+ }
72
+
73
+ /**
74
+ * Cancel any chunked render currently in flight.
75
+ * Call this before starting a new render, or when navigating away.
76
+ */
77
+ cancelActiveRender()
78
+ {
79
+ if (this._activeRenderFrame !== null)
80
+ {
81
+ cancelAnimationFrame(this._activeRenderFrame);
82
+ this._activeRenderFrame = null;
83
+ }
84
+ // Bump the token so any in-flight chunked render from a closure bails out
85
+ this._activeRenderToken++;
25
86
  }
26
87
 
27
88
  // ──────────────────────────────────────────────
@@ -31,6 +92,10 @@ class RetoldRemoteGalleryView extends libPictView
31
92
  /**
32
93
  * Render the gallery based on current state.
33
94
  * GalleryItems is already filtered+sorted by the pipeline provider.
95
+ *
96
+ * For folders with more than _CHUNKED_RENDER_THRESHOLD items, the render
97
+ * is split into chunks scheduled via requestAnimationFrame so the UI
98
+ * stays responsive. Smaller folders use the synchronous fast path.
34
99
  */
35
100
  renderGallery()
36
101
  {
@@ -40,6 +105,10 @@ class RetoldRemoteGalleryView extends libPictView
40
105
  return;
41
106
  }
42
107
 
108
+ // Cancel any chunked render already in flight — fast folder-to-folder
109
+ // navigation should not stack up render work.
110
+ this.cancelActiveRender();
111
+
43
112
  let tmpRemote = this.pict.AppData.RetoldRemote;
44
113
  let tmpItems = tmpRemote.GalleryItems || [];
45
114
  let tmpViewMode = tmpRemote.ViewMode || 'list';
@@ -69,61 +138,191 @@ class RetoldRemoteGalleryView extends libPictView
69
138
  tmpHTML += '<div>Empty folder</div>';
70
139
  tmpHTML += '</div>';
71
140
  tmpContainer.innerHTML = tmpHTML;
141
+ this._restoreSearchFocus(tmpSearchHadFocus, tmpSearchSelStart, tmpSearchSelEnd);
142
+ return;
143
+ }
72
144
 
73
- // Restore search focus even on empty results
74
- if (tmpSearchHadFocus)
145
+ // SMALL FOLDER FAST PATH: for _CHUNKED_RENDER_THRESHOLD items, render
146
+ // everything synchronously in a single innerHTML assignment. This
147
+ // preserves the existing behavior for normal-sized folders.
148
+ if (tmpItems.length <= _CHUNKED_RENDER_THRESHOLD)
149
+ {
150
+ if (tmpViewMode === 'gallery')
75
151
  {
76
- let tmpNewSearch = document.getElementById('RetoldRemote-Gallery-Search');
77
- if (tmpNewSearch)
78
- {
79
- tmpNewSearch.focus();
80
- tmpNewSearch.setSelectionRange(tmpSearchSelStart, tmpSearchSelEnd);
81
- }
152
+ tmpHTML += this._buildGridHTML(tmpItems, tmpThumbnailSize, tmpCursorIndex);
153
+ }
154
+ else
155
+ {
156
+ tmpHTML += this._buildListHTML(tmpItems, tmpCursorIndex);
157
+ }
158
+
159
+ tmpContainer.innerHTML = tmpHTML;
160
+ this._restoreSearchFocus(tmpSearchHadFocus, tmpSearchSelStart, tmpSearchSelEnd);
161
+ this._setupLazyLoading();
162
+
163
+ let tmpNavProvider = this.pict.providers['RetoldRemote-GalleryNavigation'];
164
+ if (tmpNavProvider)
165
+ {
166
+ tmpNavProvider.recalculateColumns();
167
+ }
168
+
169
+ let tmpTopBarView = this.pict.views['ContentEditor-TopBar'];
170
+ if (tmpTopBarView && tmpTopBarView.updateFilterIcon)
171
+ {
172
+ tmpTopBarView.updateFilterIcon();
82
173
  }
83
174
  return;
84
175
  }
85
176
 
86
- // Items are already filtered+sorted by the pipeline
177
+ // LARGE FOLDER CHUNKED PATH: paint the scaffolding now, then fill the
178
+ // items container in chunks via requestAnimationFrame so the main
179
+ // thread stays responsive.
180
+ let tmpItemsContainerID = 'RetoldRemote-GalleryItemsContainer';
181
+ let tmpProgressID = 'RetoldRemote-GalleryProgress';
182
+
87
183
  if (tmpViewMode === 'gallery')
88
184
  {
89
- tmpHTML += this._buildGridHTML(tmpItems, tmpThumbnailSize, tmpCursorIndex);
185
+ tmpHTML += '<div class="retold-remote-grid size-' + tmpThumbnailSize + '" id="' + tmpItemsContainerID + '"></div>';
90
186
  }
91
187
  else
92
188
  {
93
- tmpHTML += this._buildListHTML(tmpItems, tmpCursorIndex);
189
+ tmpHTML += '<div class="retold-remote-list" id="' + tmpItemsContainerID + '"></div>';
94
190
  }
95
191
 
192
+ tmpHTML += '<div class="retold-remote-gallery-progress" id="' + tmpProgressID + '">'
193
+ + '<span class="retold-remote-gallery-progress-spinner"></span>'
194
+ + '<span class="retold-remote-gallery-progress-text">Rendering 0 / ' + tmpItems.length.toLocaleString() + '\u2026</span>'
195
+ + '</div>';
196
+
96
197
  tmpContainer.innerHTML = tmpHTML;
198
+ this._restoreSearchFocus(tmpSearchHadFocus, tmpSearchSelStart, tmpSearchSelEnd);
97
199
 
98
- // Restore search input focus and cursor position after re-render
99
- if (tmpSearchHadFocus)
200
+ // Update the top bar filter icon state right away (doesn't need items)
201
+ let tmpTopBarView = this.pict.views['ContentEditor-TopBar'];
202
+ if (tmpTopBarView && tmpTopBarView.updateFilterIcon)
100
203
  {
101
- let tmpNewSearch = document.getElementById('RetoldRemote-Gallery-Search');
102
- if (tmpNewSearch)
103
- {
104
- tmpNewSearch.focus();
105
- tmpNewSearch.setSelectionRange(tmpSearchSelStart, tmpSearchSelEnd);
106
- }
204
+ tmpTopBarView.updateFilterIcon();
107
205
  }
108
206
 
109
- // Set up lazy loading for thumbnail images
110
- this._setupLazyLoading();
207
+ // Start the chunked fill
208
+ this._renderGalleryChunked(tmpItems, tmpViewMode, tmpThumbnailSize, tmpCursorIndex,
209
+ tmpItemsContainerID, tmpProgressID);
210
+ }
111
211
 
112
- // Recalculate column count for keyboard navigation
113
- let tmpNavProvider = this.pict.providers['RetoldRemote-GalleryNavigation'];
114
- if (tmpNavProvider)
212
+ /**
213
+ * Restore search input focus and selection range after a full re-render.
214
+ */
215
+ _restoreSearchFocus(pHadFocus, pSelStart, pSelEnd)
216
+ {
217
+ if (!pHadFocus)
115
218
  {
116
- tmpNavProvider.recalculateColumns();
219
+ return;
117
220
  }
118
-
119
- // Update the top bar filter icon state
120
- let tmpTopBarView = this.pict.views['ContentEditor-TopBar'];
121
- if (tmpTopBarView && tmpTopBarView.updateFilterIcon)
221
+ let tmpNewSearch = document.getElementById('RetoldRemote-Gallery-Search');
222
+ if (tmpNewSearch)
122
223
  {
123
- tmpTopBarView.updateFilterIcon();
224
+ tmpNewSearch.focus();
225
+ tmpNewSearch.setSelectionRange(pSelStart, pSelEnd);
124
226
  }
125
227
  }
126
228
 
229
+ /**
230
+ * Render the gallery items into the scaffolding container in chunks,
231
+ * scheduling each chunk with requestAnimationFrame so the main thread
232
+ * stays responsive. Updates the progress strip between chunks.
233
+ *
234
+ * @param {Array} pItems - Items to render
235
+ * @param {string} pViewMode - 'gallery' or 'list'
236
+ * @param {string} pThumbnailSize - 'small' | 'medium' | 'large'
237
+ * @param {number} pCursorIndex - Currently selected index
238
+ * @param {string} pItemsContainerID - DOM id of the inner items container
239
+ * @param {string} pProgressID - DOM id of the progress strip
240
+ */
241
+ _renderGalleryChunked(pItems, pViewMode, pThumbnailSize, pCursorIndex, pItemsContainerID, pProgressID)
242
+ {
243
+ let tmpSelf = this;
244
+ let tmpToken = ++this._activeRenderToken;
245
+ let tmpTotal = pItems.length;
246
+ let tmpOffset = 0;
247
+
248
+ let _renderNextChunk = function ()
249
+ {
250
+ // If another render started while we were waiting, bail out
251
+ if (tmpToken !== tmpSelf._activeRenderToken)
252
+ {
253
+ return;
254
+ }
255
+
256
+ let tmpItemsContainer = document.getElementById(pItemsContainerID);
257
+ if (!tmpItemsContainer)
258
+ {
259
+ // Container was replaced (e.g., navigated away) — stop rendering
260
+ return;
261
+ }
262
+
263
+ // First chunk is smaller so it paints within one frame; later chunks
264
+ // are larger for throughput
265
+ let tmpChunkSize = (tmpOffset === 0) ? _CHUNK_FIRST_SIZE : _CHUNK_SUBSEQUENT_SIZE;
266
+ let tmpEnd = Math.min(tmpOffset + tmpChunkSize, tmpTotal);
267
+ let tmpSlice = pItems.slice(tmpOffset, tmpEnd);
268
+
269
+ let tmpChunkHTML;
270
+ if (pViewMode === 'gallery')
271
+ {
272
+ tmpChunkHTML = tmpSelf._buildGridItemsHTML(tmpSlice, pCursorIndex, tmpOffset);
273
+ }
274
+ else
275
+ {
276
+ tmpChunkHTML = tmpSelf._buildListItemsHTML(tmpSlice, pCursorIndex, tmpOffset);
277
+ }
278
+
279
+ tmpItemsContainer.insertAdjacentHTML('beforeend', tmpChunkHTML);
280
+
281
+ tmpOffset = tmpEnd;
282
+
283
+ // Update progress strip
284
+ let tmpProgressEl = document.getElementById(pProgressID);
285
+ if (tmpProgressEl)
286
+ {
287
+ let tmpProgressText = tmpProgressEl.querySelector('.retold-remote-gallery-progress-text');
288
+ if (tmpProgressText)
289
+ {
290
+ tmpProgressText.textContent = 'Rendering ' + tmpOffset.toLocaleString() + ' / ' + tmpTotal.toLocaleString() + '\u2026';
291
+ }
292
+ }
293
+
294
+ if (tmpOffset < tmpTotal)
295
+ {
296
+ // Incrementally wire up lazy loading for the chunk just appended
297
+ // so thumbnails start loading while later chunks are still rendering
298
+ tmpSelf._observeNewThumbnails();
299
+ tmpSelf._activeRenderFrame = requestAnimationFrame(_renderNextChunk);
300
+ }
301
+ else
302
+ {
303
+ // Final chunk rendered — tear down progress strip and finalize
304
+ tmpSelf._activeRenderFrame = null;
305
+
306
+ if (tmpProgressEl && tmpProgressEl.parentElement)
307
+ {
308
+ tmpProgressEl.parentElement.removeChild(tmpProgressEl);
309
+ }
310
+
311
+ tmpSelf._setupLazyLoading();
312
+
313
+ let tmpNavProvider = tmpSelf.pict.providers['RetoldRemote-GalleryNavigation'];
314
+ if (tmpNavProvider)
315
+ {
316
+ tmpNavProvider.recalculateColumns();
317
+ }
318
+ }
319
+ };
320
+
321
+ // Kick off the first chunk on the next frame — gives the browser time
322
+ // to paint the scaffolding + progress strip first
323
+ this._activeRenderFrame = requestAnimationFrame(_renderNextChunk);
324
+ }
325
+
127
326
  // ──────────────────────────────────────────────
128
327
  // Header
129
328
  // ──────────────────────────────────────────────
@@ -413,25 +612,44 @@ class RetoldRemoteGalleryView extends libPictView
413
612
  // ──────────────────────────────────────────────
414
613
 
415
614
  /**
416
- * Build the grid view HTML.
615
+ * Build the grid view HTML — full wrapper + all items.
616
+ * Used by the synchronous fast path for small folders.
417
617
  */
418
618
  _buildGridHTML(pItems, pThumbnailSize, pCursorIndex)
419
619
  {
420
620
  let tmpHTML = '<div class="retold-remote-grid size-' + pThumbnailSize + '">';
621
+ tmpHTML += this._buildGridItemsHTML(pItems, pCursorIndex, 0);
622
+ tmpHTML += '</div>';
623
+ return tmpHTML;
624
+ }
625
+
626
+ /**
627
+ * Build the per-item HTML for a grid view slice.
628
+ *
629
+ * @param {Array} pItems - The items to render (may be a slice of the full list)
630
+ * @param {number} pCursorIndex - Index of the currently-selected item in the FULL list
631
+ * @param {number} pStartIndex - Index of pItems[0] within the full list (for data-index and click handlers)
632
+ */
633
+ _buildGridItemsHTML(pItems, pCursorIndex, pStartIndex)
634
+ {
635
+ let tmpStartIndex = pStartIndex || 0;
636
+ let tmpHTML = '';
421
637
  let tmpProvider = this.pict.providers['RetoldRemote-Provider'];
422
638
  let tmpIconProvider = this.pict.providers['RetoldRemote-Icons'];
639
+ let tmpFmt = this.pict.providers['RetoldRemote-FormattingUtilities'];
423
640
 
424
641
  for (let i = 0; i < pItems.length; i++)
425
642
  {
426
643
  let tmpItem = pItems[i];
427
- let tmpSelectedClass = (i === pCursorIndex) ? ' selected' : '';
644
+ let tmpAbsoluteIndex = tmpStartIndex + i;
645
+ let tmpSelectedClass = (tmpAbsoluteIndex === pCursorIndex) ? ' selected' : '';
428
646
  let tmpExtension = (tmpItem.Extension || '').toLowerCase();
429
647
  let tmpCategory = this._getCategory(tmpExtension, tmpItem.Type);
430
648
 
431
649
  tmpHTML += '<div class="retold-remote-tile' + tmpSelectedClass + '" '
432
- + 'data-index="' + i + '" '
433
- + 'onclick="pict.views[\'RetoldRemote-Gallery\'].onTileClick(' + i + ')" '
434
- + 'ondblclick="pict.views[\'RetoldRemote-Gallery\'].onTileDoubleClick(' + i + ')">';
650
+ + 'data-index="' + tmpAbsoluteIndex + '" '
651
+ + 'onclick="pict.views[\'RetoldRemote-Gallery\'].onTileClick(' + tmpAbsoluteIndex + ')" '
652
+ + 'ondblclick="pict.views[\'RetoldRemote-Gallery\'].onTileDoubleClick(' + tmpAbsoluteIndex + ')">';
435
653
 
436
654
  // Thumbnail area
437
655
  tmpHTML += '<div class="retold-remote-tile-thumb">';
@@ -451,7 +669,7 @@ class RetoldRemoteGalleryView extends libPictView
451
669
  let tmpThumbURL = tmpProvider.getThumbnailURL(tmpItem.Path, 400, 300);
452
670
  if (tmpThumbURL)
453
671
  {
454
- tmpHTML += '<img data-src="' + tmpThumbURL + '" alt="' + this.pict.providers['RetoldRemote-FormattingUtilities'].escapeHTML(tmpItem.Name) + '" loading="lazy">';
672
+ tmpHTML += '<img data-src="' + tmpThumbURL + '" alt="' + tmpFmt.escapeHTML(tmpItem.Name) + '" loading="lazy">';
455
673
  }
456
674
  else
457
675
  {
@@ -466,7 +684,7 @@ class RetoldRemoteGalleryView extends libPictView
466
684
  let tmpThumbURL = tmpProvider.getThumbnailURL(tmpItem.Path, 400, 300);
467
685
  if (tmpThumbURL)
468
686
  {
469
- tmpHTML += '<img data-src="' + tmpThumbURL + '" alt="' + this.pict.providers['RetoldRemote-FormattingUtilities'].escapeHTML(tmpItem.Name) + '" loading="lazy">';
687
+ tmpHTML += '<img data-src="' + tmpThumbURL + '" alt="' + tmpFmt.escapeHTML(tmpItem.Name) + '" loading="lazy">';
470
688
  }
471
689
  else
472
690
  {
@@ -497,12 +715,12 @@ class RetoldRemoteGalleryView extends libPictView
497
715
  tmpHTML += '</div>'; // end thumb
498
716
 
499
717
  // Label
500
- tmpHTML += '<div class="retold-remote-tile-label" title="' + this.pict.providers['RetoldRemote-FormattingUtilities'].escapeHTML(tmpItem.Name) + '">' + this.pict.providers['RetoldRemote-FormattingUtilities'].escapeHTML(tmpItem.Name) + '</div>';
718
+ tmpHTML += '<div class="retold-remote-tile-label" title="' + tmpFmt.escapeHTML(tmpItem.Name) + '">' + tmpFmt.escapeHTML(tmpItem.Name) + '</div>';
501
719
 
502
720
  // Meta
503
721
  if (tmpItem.Type === 'file' && tmpItem.Size !== undefined)
504
722
  {
505
- tmpHTML += '<div class="retold-remote-tile-meta">' + this.pict.providers['RetoldRemote-FormattingUtilities'].formatFileSize(tmpItem.Size) + '</div>';
723
+ tmpHTML += '<div class="retold-remote-tile-meta">' + tmpFmt.formatFileSize(tmpItem.Size) + '</div>';
506
724
  }
507
725
  else if (tmpItem.Type === 'folder')
508
726
  {
@@ -510,34 +728,51 @@ class RetoldRemoteGalleryView extends libPictView
510
728
  }
511
729
  else if (tmpItem.Type === 'archive')
512
730
  {
513
- tmpHTML += '<div class="retold-remote-tile-meta">Archive' + (tmpItem.Size ? ' · ' + this.pict.providers['RetoldRemote-FormattingUtilities'].formatFileSize(tmpItem.Size) : '') + '</div>';
731
+ tmpHTML += '<div class="retold-remote-tile-meta">Archive' + (tmpItem.Size ? ' \u00b7 ' + tmpFmt.formatFileSize(tmpItem.Size) : '') + '</div>';
514
732
  }
515
733
 
516
734
  tmpHTML += '</div>'; // end tile
517
735
  }
518
736
 
519
- tmpHTML += '</div>'; // end grid
520
-
521
737
  return tmpHTML;
522
738
  }
523
739
 
524
740
  /**
525
- * Build the list view HTML.
741
+ * Build the list view HTML — full wrapper + all items.
742
+ * Used by the synchronous fast path for small folders.
526
743
  */
527
744
  _buildListHTML(pItems, pCursorIndex)
528
745
  {
746
+ let tmpHTML = '<div class="retold-remote-list">';
747
+ tmpHTML += this._buildListItemsHTML(pItems, pCursorIndex, 0);
748
+ tmpHTML += '</div>';
749
+ return tmpHTML;
750
+ }
751
+
752
+ /**
753
+ * Build the per-item HTML for a list view slice.
754
+ *
755
+ * @param {Array} pItems - The items to render (may be a slice of the full list)
756
+ * @param {number} pCursorIndex - Index of the currently-selected item in the FULL list
757
+ * @param {number} pStartIndex - Index of pItems[0] within the full list
758
+ */
759
+ _buildListItemsHTML(pItems, pCursorIndex, pStartIndex)
760
+ {
761
+ let tmpStartIndex = pStartIndex || 0;
529
762
  let tmpRemote = this.pict.AppData.RetoldRemote;
530
763
  let tmpShowExt = tmpRemote.ListShowExtension !== false;
531
764
  let tmpShowSize = tmpRemote.ListShowSize !== false;
532
765
  let tmpShowDate = tmpRemote.ListShowDate !== false;
533
766
 
534
- let tmpHTML = '<div class="retold-remote-list">';
767
+ let tmpHTML = '';
535
768
  let tmpIconProvider = this.pict.providers['RetoldRemote-Icons'];
769
+ let tmpFmt = this.pict.providers['RetoldRemote-FormattingUtilities'];
536
770
 
537
771
  for (let i = 0; i < pItems.length; i++)
538
772
  {
539
773
  let tmpItem = pItems[i];
540
- let tmpSelectedClass = (i === pCursorIndex) ? ' selected' : '';
774
+ let tmpAbsoluteIndex = tmpStartIndex + i;
775
+ let tmpSelectedClass = (tmpAbsoluteIndex === pCursorIndex) ? ' selected' : '';
541
776
  let tmpIcon = '';
542
777
  if (tmpIconProvider)
543
778
  {
@@ -545,16 +780,16 @@ class RetoldRemoteGalleryView extends libPictView
545
780
  }
546
781
 
547
782
  tmpHTML += '<div class="retold-remote-list-row' + tmpSelectedClass + '" '
548
- + 'data-index="' + i + '" '
549
- + 'onclick="pict.views[\'RetoldRemote-Gallery\'].onTileClick(' + i + ')" '
550
- + 'ondblclick="pict.views[\'RetoldRemote-Gallery\'].onTileDoubleClick(' + i + ')">';
783
+ + 'data-index="' + tmpAbsoluteIndex + '" '
784
+ + 'onclick="pict.views[\'RetoldRemote-Gallery\'].onTileClick(' + tmpAbsoluteIndex + ')" '
785
+ + 'ondblclick="pict.views[\'RetoldRemote-Gallery\'].onTileDoubleClick(' + tmpAbsoluteIndex + ')">';
551
786
 
552
787
  tmpHTML += '<div class="retold-remote-list-icon">' + tmpIcon + '</div>';
553
- tmpHTML += '<div class="retold-remote-list-name" title="' + this.pict.providers['RetoldRemote-FormattingUtilities'].escapeHTML(tmpItem.Name) + '"'
554
- + ' ontouchstart="pict.views[\'RetoldRemote-Gallery\']._onNameTouchStart(event, \'' + this.pict.providers['RetoldRemote-FormattingUtilities'].escapeHTML(tmpItem.Name).replace(/'/g, '\\&#39;') + '\')"'
788
+ tmpHTML += '<div class="retold-remote-list-name" title="' + tmpFmt.escapeHTML(tmpItem.Name) + '"'
789
+ + ' ontouchstart="pict.views[\'RetoldRemote-Gallery\']._onNameTouchStart(event, \'' + tmpFmt.escapeHTML(tmpItem.Name).replace(/'/g, '\\&#39;') + '\')"'
555
790
  + ' ontouchend="pict.views[\'RetoldRemote-Gallery\']._onNameTouchEnd(event)"'
556
791
  + ' ontouchcancel="pict.views[\'RetoldRemote-Gallery\']._onNameTouchEnd(event)"'
557
- + '>' + this.pict.providers['RetoldRemote-FormattingUtilities'].escapeHTML(tmpItem.Name) + '</div>';
792
+ + '>' + tmpFmt.escapeHTML(tmpItem.Name) + '</div>';
558
793
 
559
794
  // Extension column
560
795
  if (tmpShowExt)
@@ -576,7 +811,7 @@ class RetoldRemoteGalleryView extends libPictView
576
811
  {
577
812
  if ((tmpItem.Type === 'file' || tmpItem.Type === 'archive') && tmpItem.Size !== undefined)
578
813
  {
579
- tmpHTML += '<div class="retold-remote-list-size">' + this.pict.providers['RetoldRemote-FormattingUtilities'].formatFileSize(tmpItem.Size) + '</div>';
814
+ tmpHTML += '<div class="retold-remote-list-size">' + tmpFmt.formatFileSize(tmpItem.Size) + '</div>';
580
815
  }
581
816
  else
582
817
  {
@@ -589,7 +824,7 @@ class RetoldRemoteGalleryView extends libPictView
589
824
  {
590
825
  if (tmpItem.Modified)
591
826
  {
592
- tmpHTML += '<div class="retold-remote-list-date">' + this.pict.providers['RetoldRemote-FormattingUtilities'].formatShortDate(tmpItem.Modified) + '</div>';
827
+ tmpHTML += '<div class="retold-remote-list-date">' + tmpFmt.formatShortDate(tmpItem.Modified) + '</div>';
593
828
  }
594
829
  else
595
830
  {
@@ -600,8 +835,6 @@ class RetoldRemoteGalleryView extends libPictView
600
835
  tmpHTML += '</div>';
601
836
  }
602
837
 
603
- tmpHTML += '</div>';
604
-
605
838
  return tmpHTML;
606
839
  }
607
840
 
@@ -611,6 +844,8 @@ class RetoldRemoteGalleryView extends libPictView
611
844
 
612
845
  /**
613
846
  * Set up IntersectionObserver for lazy-loading thumbnail images.
847
+ * Uses a bounded concurrency queue so we don't stampede the server
848
+ * with hundreds of parallel thumbnail requests on the initial render.
614
849
  */
615
850
  _setupLazyLoading()
616
851
  {
@@ -619,12 +854,13 @@ class RetoldRemoteGalleryView extends libPictView
619
854
  this._intersectionObserver.disconnect();
620
855
  }
621
856
 
622
- let tmpImages = document.querySelectorAll('.retold-remote-tile-thumb img[data-src]');
623
- if (tmpImages.length === 0)
624
- {
625
- return;
626
- }
857
+ // Reset the concurrency queue. Any in-flight loads from the previous
858
+ // render will finish on their own; they just won't trigger queue drain
859
+ // because the counter is reset.
860
+ this._thumbnailQueue = [];
861
+ this._thumbnailInFlight = 0;
627
862
 
863
+ let tmpSelf = this;
628
864
  this._intersectionObserver = new IntersectionObserver(
629
865
  (pEntries) =>
630
866
  {
@@ -633,21 +869,86 @@ class RetoldRemoteGalleryView extends libPictView
633
869
  if (pEntries[i].isIntersecting)
634
870
  {
635
871
  let tmpImg = pEntries[i].target;
636
- tmpImg.src = tmpImg.getAttribute('data-src');
637
- tmpImg.removeAttribute('data-src');
638
- this._intersectionObserver.unobserve(tmpImg);
872
+ tmpSelf._intersectionObserver.unobserve(tmpImg);
873
+ tmpSelf._enqueueThumbnail(tmpImg);
639
874
  }
640
875
  }
641
876
  },
642
- { rootMargin: '200px' }
877
+ { rootMargin: '100px' }
643
878
  );
644
879
 
880
+ // Observe every img[data-src] currently in the DOM. Chunked renders
881
+ // also call _observeNewThumbnails() incrementally so thumbnails start
882
+ // loading while later chunks are still being built.
883
+ this._observeNewThumbnails();
884
+ }
885
+
886
+ /**
887
+ * Observe any img[data-src] nodes that are not yet being watched by
888
+ * the IntersectionObserver. Called incrementally from the chunked
889
+ * render after each chunk is appended, so visible thumbnails in the
890
+ * first chunk can start loading while later chunks render.
891
+ */
892
+ _observeNewThumbnails()
893
+ {
894
+ if (!this._intersectionObserver)
895
+ {
896
+ return;
897
+ }
898
+ // Query only images still carrying data-src (new ones from the latest
899
+ // chunk, or ones that haven't intersected yet)
900
+ let tmpImages = document.querySelectorAll('.retold-remote-tile-thumb img[data-src]:not([data-observed])');
645
901
  for (let i = 0; i < tmpImages.length; i++)
646
902
  {
903
+ tmpImages[i].setAttribute('data-observed', '1');
647
904
  this._intersectionObserver.observe(tmpImages[i]);
648
905
  }
649
906
  }
650
907
 
908
+ /**
909
+ * Enqueue a thumbnail for lazy-loading with bounded concurrency.
910
+ * Dispatches immediately if under the concurrency cap.
911
+ *
912
+ * @param {HTMLImageElement} pImg - The img element to load
913
+ */
914
+ _enqueueThumbnail(pImg)
915
+ {
916
+ this._thumbnailQueue.push(pImg);
917
+ this._drainThumbnailQueue();
918
+ }
919
+
920
+ /**
921
+ * Dispatch pending thumbnails until the in-flight count hits the cap.
922
+ */
923
+ _drainThumbnailQueue()
924
+ {
925
+ let tmpSelf = this;
926
+ while (this._thumbnailInFlight < _MAX_THUMBNAIL_CONCURRENCY && this._thumbnailQueue.length > 0)
927
+ {
928
+ let tmpImg = this._thumbnailQueue.shift();
929
+ let tmpSrc = tmpImg.getAttribute('data-src');
930
+ if (!tmpSrc)
931
+ {
932
+ // Already loaded or cleared — skip
933
+ continue;
934
+ }
935
+
936
+ this._thumbnailInFlight++;
937
+
938
+ let _finish = function ()
939
+ {
940
+ tmpSelf._thumbnailInFlight--;
941
+ tmpSelf._drainThumbnailQueue();
942
+ };
943
+
944
+ tmpImg.addEventListener('load', _finish, { once: true });
945
+ tmpImg.addEventListener('error', _finish, { once: true });
946
+
947
+ tmpImg.src = tmpSrc;
948
+ tmpImg.removeAttribute('data-src');
949
+ }
950
+ }
951
+
651
952
  // ──────────────────────────────────────────────
652
953
  // Event handlers
653
954
  // ──────────────────────────────────────────────