perf(files-ui): stream media/downloads natively, rAF rubber-band, Map selection

Four related fixes to stop buffering files in page memory and stop
hammering layout from the selection paths:

- Inline viewer media: video/audio fetched the ENTIRE file into a blob
  before the first frame (a 2 GB video = 2 GB of tab heap, no
  progressive playback, no seek). The <video>/<audio> src now points
  straight at the same-origin API URL — cookies travel automatically
  and the browser streams with native Range requests, exactly like the
  music player already did.

- Downloads (file, folder ZIP, batch ZIP, viewer button): the
  fetch → blob → objectURL pattern materialized the whole payload in
  RAM before the save dialog appeared (a 10 GB batch ZIP risked
  crashing the tab) with no download progress UI. New shared
  utils/download.js hands the URL to the browser, which streams to
  disk with its own progress UI. Batch download uses the existing GET
  endpoint (same URL contract as the drag-out DownloadURL, now shared
  via buildBatchDownloadUrl); selections whose id list cannot fit in a
  URL keep the buffered POST fallback. Trade-off: failed downloads now
  surface in the browser's download shelf instead of an in-app toast.

- Rubber-band lasso: every mousemove (>100/s) walked all cards
  interleaving getBoundingClientRect() reads with class writes — up to
  N forced reflows per event, freezing the frame rate on folders with
  thousands of loaded rows. Card rects (+ item info) are now snapshot
  once per drag (rebuilt on scroll), and a single rAF pass per frame
  compares against the cached geometry, touching only cards whose
  selection state changed.

- batchToolbar: getSelection() rebuilt the selection by scanning every
  .file-item in the document (the TODO admitted it); it now reads the
  _selected Map that every selection path already keeps in sync.
  clear() scopes its DOM sweep to #files-list (the only container the
  toolbar manages) instead of the whole document.

https://claude.ai/code/session_01Dp3oWon5GBMVn4j3QXZdgx
This commit is contained in:
Claude
2026-06-10 09:02:13 +00:00
parent 8d040314a3
commit fd80a3de67
5 changed files with 232 additions and 186 deletions
+35 -17
View File
@@ -15,6 +15,7 @@ import { loadFiles } from '../../app/filesView.js';
import { app } from '../../app/state.js';
import { showConfirmDialog, ui } from '../../app/ui.js';
import { i18n } from '../../core/i18n.js';
import { buildBatchDownloadUrl, triggerBrowserDownload } from '../../utils/download.js';
import { favorites } from '../library/favorites.js';
import { contextMenus } from './contextMenus.js';
import { getAuthHeaders } from './fileOperations.js';
@@ -126,10 +127,14 @@ const batchToolbar = {
clear() {
this._selected.clear();
this._lastClickedIndex = -1;
document.querySelectorAll('.file-item.selected').forEach((el) => {
// Scope the DOM sweep to the files list — the only container this
// toolbar manages (see `selectAll`) — instead of the whole document,
// and only touch checkboxes that are actually checked.
const list = document.getElementById('files-list');
list?.querySelectorAll('.file-item.selected').forEach((el) => {
el.classList.remove('selected');
});
document.querySelectorAll('.item-checkbox').forEach((cb) => {
list?.querySelectorAll('.item-checkbox:checked').forEach((cb) => {
/** @type {HTMLInputElement} */ (cb).checked = false;
});
// Reset the active component's internal selection state without going
@@ -173,15 +178,16 @@ const batchToolbar = {
/** @type {Array<string>} */
const folderIds = [];
// TODO optimize & check if _selected is a better use
/** @type {NodeListOf<HTMLDivElement>} */ (document.querySelectorAll(`div.file-item.selected`)).forEach((item) => {
if (item.dataset.fileId) {
fileIds.push(item.dataset.fileId);
} else {
// ignore selectedItem if this is the target
if (targtFolderId && targtFolderId !== item.dataset.folderId) folderIds.push(item.dataset.folderId);
// `_selected` is the source of truth (every selection path keeps it
// in sync) — no need to re-derive the selection from a DOM scan.
for (const sel of this._selected.values()) {
if (sel.type === 'file') {
fileIds.push(sel.id);
} else if (targtFolderId && targtFolderId !== sel.id) {
// ignore the selected folder if it is the drop target itself
folderIds.push(sel.id);
}
});
}
return {
fileIds: fileIds,
@@ -454,10 +460,22 @@ const batchToolbar = {
ui.showNotification('Preparing download', 'Creating ZIP archive...');
try {
const fileIds = items.filter((i) => i.type === 'file').map((i) => i.id);
const folderIds = items.filter((i) => i.type === 'folder').map((i) => i.id);
const fileIds = items.filter((i) => i.type === 'file').map((i) => i.id);
const folderIds = items.filter((i) => i.type === 'folder').map((i) => i.id);
const zipName = `oxicloud-download-${Date.now()}.zip`;
// Browser-native download via the GET variant of the endpoint: the
// ZIP streams to disk instead of being buffered whole in the tab's
// memory (a multi-GB selection used to risk crashing the tab).
const url = buildBatchDownloadUrl(fileIds, folderIds);
if (url.length <= 4000) {
triggerBrowserDownload(url, zipName);
return;
}
// Selections too large for a URL (~100+ items) keep the buffered
// POST path — the id list only fits in a request body.
try {
const response = await fetch('/api/batch/download', {
method: 'POST',
headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' },
@@ -467,14 +485,14 @@ const batchToolbar = {
if (!response.ok) throw new Error(`Server returned ${response.status}`);
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const blobUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `oxicloud-download-${Date.now()}.zip`;
link.href = blobUrl;
link.download = zipName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
URL.revokeObjectURL(blobUrl);
} catch (e) {
console.error('Batch download error:', e);
ui.showNotification('Error', 'Could not download selected items');
+8 -46
View File
@@ -10,6 +10,7 @@ import { showConfirmDialog, ui } from '../../app/ui.js';
import { getCsrfHeaders, getCsrfToken } from '../../core/csrf.js';
import { i18n } from '../../core/i18n.js';
import { notifications } from '../../core/notifications.js';
import { triggerBrowserDownload } from '../../utils/download.js';
/**
* @typedef {Object} BatchResult
@@ -1369,32 +1370,13 @@ const fileOps = {
},
/**
* Download a file
* Download a file — handed to the browser so it streams to disk with
* its native download UI instead of buffering the file in memory.
* @param {string} fileId - File ID
* @param {string} fileName - File name
*/
async downloadFile(fileId, fileName) {
try {
const response = await fetch(`/api/files/${fileId}`, {
headers: getAuthHeaders()
});
if (response.ok) {
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
} else {
ui.showNotification('Error', 'Error downloading the file');
}
} catch (error) {
console.error('Error downloading file:', error);
ui.showNotification('Error', 'Error downloading the file');
}
triggerBrowserDownload(`/api/files/${fileId}`, fileName);
},
/**
@@ -1403,30 +1385,10 @@ const fileOps = {
* @param {string} folderName - Folder name
*/
async downloadFolder(folderId, folderName) {
try {
// Show notification to user
ui.showNotification('Preparing download', 'Preparing the folder for download...');
const response = await fetch(`/api/folders/${folderId}/download?format=zip`, {
headers: getAuthHeaders()
});
if (response.ok) {
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${folderName}.zip`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
} else {
ui.showNotification('Error', 'Error downloading the folder');
}
} catch (error) {
console.error('Error downloading folder:', error);
ui.showNotification('Error', 'Error downloading the folder');
}
// Show notification to user (the server still has to assemble the
// ZIP before the browser's own download UI takes over).
ui.showNotification('Preparing download', 'Preparing the folder for download...');
triggerBrowserDownload(`/api/folders/${folderId}/download?format=zip`, `${folderName}.zip`);
}
};
+63 -95
View File
@@ -6,6 +6,7 @@
import { updateHistory } from '../../app/main.js';
import { app } from '../../app/state.js';
import { isTextViewable } from '../../core/formatters.js';
import { triggerBrowserDownload } from '../../utils/download.js';
import { wopiEditor } from './wopiEditor.js';
/** @import {FileItem} from '../../core/types.js' */
@@ -397,111 +398,92 @@ class InlineViewer {
}
/**
* Creates an audio or video player using blob URL (authenticated fetch)
* Creates an audio or video player that streams straight from the API.
* The element's `src` points at the same-origin endpoint (cookies are
* sent automatically), so the browser issues Range requests and starts
* playback progressively — the file is never materialized in memory,
* and seeking works without downloading everything first.
* @param {FileItem} file
* @param {string} mediaType
* @param {HTMLDivElement} container
* @param {HTMLDivElement} loader
*/
async createMediaViewer(file, mediaType, container, loader) {
try {
console.log(`Creating ${mediaType} player for:`, file.name);
createMediaViewer(file, mediaType, container, loader) {
console.log(`Creating ${mediaType} player for:`, file.name);
// Fetch file (cookie auto-sent)
const response = await fetch(`/api/files/${file.id}?inline=true`, {
credentials: 'same-origin'
});
const streamUrl = `/api/files/${file.id}?inline=true`;
if (!response.ok) {
throw new Error(`Error fetching file: ${response.status} ${response.statusText}`);
}
// The native player has its own buffering UI — drop our spinner now.
if (loader?.parentNode) {
loader.parentNode.removeChild(loader);
}
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
if (mediaType === 'audio') {
// Wrapper with icon + player
const wrapper = document.createElement('div');
wrapper.className = 'inline-viewer-audio-wrapper';
// Remove loader
if (loader?.parentNode) {
loader.parentNode.removeChild(loader);
}
const icon = document.createElement('div');
icon.className = 'inline-viewer-audio-icon';
icon.innerHTML = '<i class="fas fa-music"></i>';
wrapper.appendChild(icon);
if (mediaType === 'audio') {
// Wrapper with icon + player
const wrapper = document.createElement('div');
wrapper.className = 'inline-viewer-audio-wrapper';
const nameEl = document.createElement('div');
nameEl.className = 'inline-viewer-audio-name';
nameEl.textContent = file.name;
wrapper.appendChild(nameEl);
const icon = document.createElement('div');
icon.className = 'inline-viewer-audio-icon';
icon.innerHTML = '<i class="fas fa-music"></i>';
wrapper.appendChild(icon);
const audio = document.createElement('audio');
audio.className = 'inline-viewer-audio';
audio.controls = true;
audio.preload = 'metadata';
audio.src = streamUrl;
wrapper.appendChild(audio);
const nameEl = document.createElement('div');
nameEl.className = 'inline-viewer-audio-name';
nameEl.textContent = file.name;
wrapper.appendChild(nameEl);
const audio = document.createElement('audio');
audio.className = 'inline-viewer-audio';
audio.controls = true;
audio.preload = 'metadata';
audio.src = blobUrl;
wrapper.appendChild(audio);
// Fallback message for unsupported codecs
audio.addEventListener('error', () => {
console.warn('Audio playback error — codec may not be supported');
wrapper.innerHTML = '';
const msg = document.createElement('div');
msg.className = 'inline-viewer-message';
msg.innerHTML = `
// Fallback message for unsupported codecs / failed loads
audio.addEventListener('error', () => {
console.warn('Audio playback error — codec may not be supported');
wrapper.innerHTML = '';
const msg = document.createElement('div');
msg.className = 'inline-viewer-message';
msg.innerHTML = `
<div class="inline-viewer-icon"><i class="fas fa-exclamation-circle"></i></div>
<div class="inline-viewer-text">
<p>Your browser cannot play this audio format.</p>
<p>Click "Download" to save the file.</p>
</div>
`;
wrapper.appendChild(msg);
});
wrapper.appendChild(msg);
});
container.appendChild(wrapper);
} else {
const video = document.createElement('video');
video.className = 'inline-viewer-video';
video.controls = true;
video.preload = 'metadata';
video.src = blobUrl;
video.setAttribute('playsinline', 'true');
container.appendChild(wrapper);
} else {
const video = document.createElement('video');
video.className = 'inline-viewer-video';
video.controls = true;
video.preload = 'metadata';
video.src = streamUrl;
video.setAttribute('playsinline', 'true');
// Fallback message for unsupported codecs
video.addEventListener('error', () => {
console.warn('Video playback error — codec may not be supported');
if (video.parentNode) {
video.parentNode.removeChild(video);
}
const msg = document.createElement('div');
msg.className = 'inline-viewer-message';
msg.innerHTML = `
// Fallback message for unsupported codecs / failed loads
video.addEventListener('error', () => {
console.warn('Video playback error — codec may not be supported');
if (video.parentNode) {
video.parentNode.removeChild(video);
}
const msg = document.createElement('div');
msg.className = 'inline-viewer-message';
msg.innerHTML = `
<div class="inline-viewer-icon"><i class="fas fa-exclamation-circle"></i></div>
<div class="inline-viewer-text">
<p>Your browser cannot play this video format.</p>
<p>Click "Download" to save the file.</p>
</div>
`;
container.appendChild(msg);
});
container.appendChild(msg);
});
container.appendChild(video);
}
// Store blob URL for cleanup on close
this.currentBlobUrl = blobUrl;
} catch (error) {
console.error(`Error creating ${mediaType} viewer:`, error);
if (loader?.parentNode) {
loader.parentNode.removeChild(loader);
}
this.showErrorMessage(container);
container.appendChild(video);
}
}
@@ -549,26 +531,12 @@ class InlineViewer {
}
/**
*
* Download the file via a browser-native download (streams to disk,
* nothing is buffered in page memory).
* @param {FileItem} file
*/
downloadFile(file) {
fetch(`/api/files/${file.id}`, { credentials: 'same-origin' })
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.blob();
})
.then((blob) => {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = file.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
})
.catch((err) => console.error('Download error:', err));
triggerBrowserDownload(`/api/files/${file.id}`, file.name);
}
/**