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:
+82
-28
@@ -14,6 +14,7 @@ import { fileOps } from '../features/files/fileOperations.js';
|
|||||||
import { inlineViewer } from '../features/files/inlineViewer.js';
|
import { inlineViewer } from '../features/files/inlineViewer.js';
|
||||||
import { wopiEditor } from '../features/files/wopiEditor.js';
|
import { wopiEditor } from '../features/files/wopiEditor.js';
|
||||||
import { recent } from '../features/library/recent.js';
|
import { recent } from '../features/library/recent.js';
|
||||||
|
import { buildBatchDownloadUrl } from '../utils/download.js';
|
||||||
import { positionMenu } from '../utils/menuPosition.js';
|
import { positionMenu } from '../utils/menuPosition.js';
|
||||||
import { loadFiles } from './filesView.js';
|
import { loadFiles } from './filesView.js';
|
||||||
import { updateHistory } from './main.js';
|
import { updateHistory } from './main.js';
|
||||||
@@ -774,7 +775,7 @@ const ui = {
|
|||||||
if (item?.type === 'file') fileIds.push(item.id);
|
if (item?.type === 'file') fileIds.push(item.id);
|
||||||
else if (item) folderIds.push(item.id);
|
else if (item) folderIds.push(item.id);
|
||||||
});
|
});
|
||||||
downloadUrl = `${window.location.origin}/api/batch/download?file_ids=${fileIds.join(',')}&folder_ids=${folderIds.join(',')}`;
|
downloadUrl = `${window.location.origin}${buildBatchDownloadUrl(fileIds, folderIds)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
e.dataTransfer.setData('DownloadURL', `application/octet-stream:${nameEncoded}:${downloadUrl}`);
|
e.dataTransfer.setData('DownloadURL', `application/octet-stream:${nameEncoded}:${downloadUrl}`);
|
||||||
@@ -1099,6 +1100,69 @@ function initRubberBandSelection() {
|
|||||||
let active = false;
|
let active = false;
|
||||||
let startX = 0,
|
let startX = 0,
|
||||||
startY = 0;
|
startY = 0;
|
||||||
|
let curX = 0,
|
||||||
|
curY = 0;
|
||||||
|
let rafId = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Card geometry snapshot taken once per drag (and rebuilt on scroll).
|
||||||
|
* Comparing the lasso against these cached rects means the per-frame
|
||||||
|
* pass performs zero DOM reads — no forced reflow per card.
|
||||||
|
* @type {Array<{el: HTMLElement, left: number, top: number, right: number,
|
||||||
|
* bottom: number, info: ReturnType<typeof batchToolbar._extractInfo>,
|
||||||
|
* selected: boolean}> | null}
|
||||||
|
*/
|
||||||
|
let cardCache = null;
|
||||||
|
|
||||||
|
const buildCardCache = () => {
|
||||||
|
cardCache = [];
|
||||||
|
document.querySelectorAll('#files-list .file-item').forEach((card) => {
|
||||||
|
const el = /** @type {HTMLElement} */ (card);
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
cardCache.push({
|
||||||
|
el,
|
||||||
|
left: r.left,
|
||||||
|
top: r.top,
|
||||||
|
right: r.right,
|
||||||
|
bottom: r.bottom,
|
||||||
|
info: batchToolbar ? batchToolbar._extractInfo(/** @type {HTMLDivElement} */ (el)) : null,
|
||||||
|
selected: el.classList.contains('selected')
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Scrolling mid-drag shifts every viewport rect — drop the snapshot so
|
||||||
|
// the next frame rebuilds it.
|
||||||
|
const invalidateCardCache = () => {
|
||||||
|
cardCache = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** One classification pass per animation frame (cached rects only). */
|
||||||
|
const classifyCards = () => {
|
||||||
|
rafId = 0;
|
||||||
|
if (!cardCache) buildCardCache();
|
||||||
|
|
||||||
|
const left = Math.min(startX, curX);
|
||||||
|
const top = Math.min(startY, curY);
|
||||||
|
const right = Math.max(startX, curX);
|
||||||
|
const bottom = Math.max(startY, curY);
|
||||||
|
|
||||||
|
for (const entry of cardCache) {
|
||||||
|
const intersects = entry.left < right && entry.right > left && entry.top < bottom && entry.bottom > top;
|
||||||
|
if (intersects === entry.selected) continue;
|
||||||
|
entry.selected = intersects;
|
||||||
|
entry.el.classList.toggle('selected', intersects);
|
||||||
|
|
||||||
|
// Sync with batchToolbar module (only on state change)
|
||||||
|
if (batchToolbar && entry.info) {
|
||||||
|
if (intersects) {
|
||||||
|
batchToolbar.select(entry.info.id, entry.info.name, entry.info.type, entry.info.parentId);
|
||||||
|
} else {
|
||||||
|
batchToolbar.deselect(entry.info.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// We listen on the whole files-container (covers grid + empty space)
|
// We listen on the whole files-container (covers grid + empty space)
|
||||||
const container = document.querySelector('.files-container') || document.getElementById('files-list');
|
const container = document.querySelector('.files-container') || document.getElementById('files-list');
|
||||||
@@ -1123,6 +1187,10 @@ function initRubberBandSelection() {
|
|||||||
active = true;
|
active = true;
|
||||||
startX = e.clientX;
|
startX = e.clientX;
|
||||||
startY = e.clientY;
|
startY = e.clientY;
|
||||||
|
curX = startX;
|
||||||
|
curY = startY;
|
||||||
|
cardCache = null; // built lazily on the first classification frame
|
||||||
|
document.addEventListener('scroll', invalidateCardCache, { capture: true, passive: true });
|
||||||
|
|
||||||
selRect.style.left = `${startX}px`;
|
selRect.style.left = `${startX}px`;
|
||||||
selRect.style.top = `${startY}px`;
|
selRect.style.top = `${startY}px`;
|
||||||
@@ -1136,8 +1204,8 @@ function initRubberBandSelection() {
|
|||||||
document.addEventListener('mousemove', (e) => {
|
document.addEventListener('mousemove', (e) => {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
|
|
||||||
const curX = e.clientX;
|
curX = e.clientX;
|
||||||
const curY = e.clientY;
|
curY = e.clientY;
|
||||||
|
|
||||||
const left = Math.min(startX, curX);
|
const left = Math.min(startX, curX);
|
||||||
const top = Math.min(startY, curY);
|
const top = Math.min(startY, curY);
|
||||||
@@ -1149,41 +1217,27 @@ function initRubberBandSelection() {
|
|||||||
selRect.style.display = 'block';
|
selRect.style.display = 'block';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Style writes only — no layout reads here. The card highlighting
|
||||||
|
// runs at most once per frame against the cached geometry.
|
||||||
selRect.style.left = `${left}px`;
|
selRect.style.left = `${left}px`;
|
||||||
selRect.style.top = `${top}px`;
|
selRect.style.top = `${top}px`;
|
||||||
selRect.style.width = `${width}px`;
|
selRect.style.width = `${width}px`;
|
||||||
selRect.style.height = `${height}px`;
|
selRect.style.height = `${height}px`;
|
||||||
|
|
||||||
// Highlight cards that intersect with the rectangle
|
if (!rafId) rafId = requestAnimationFrame(classifyCards);
|
||||||
const rectBounds = { left, top, right: left + width, bottom: top + height };
|
|
||||||
|
|
||||||
document.querySelectorAll('#files-list .file-item').forEach((card) => {
|
|
||||||
const cardRect = card.getBoundingClientRect();
|
|
||||||
const intersects =
|
|
||||||
cardRect.left < rectBounds.right && cardRect.right > rectBounds.left && cardRect.top < rectBounds.bottom && cardRect.bottom > rectBounds.top;
|
|
||||||
|
|
||||||
if (intersects) {
|
|
||||||
card.classList.add('selected');
|
|
||||||
|
|
||||||
// Sync with batchToolbar module
|
|
||||||
if (batchToolbar) {
|
|
||||||
const info = batchToolbar._extractInfo(/** @type {HTMLDivElement} */ (card));
|
|
||||||
if (info) batchToolbar.select(info.id, info.name, info.type, info.parentId);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
card.classList.remove('selected');
|
|
||||||
// Deselect from batchToolbar module
|
|
||||||
if (batchToolbar) {
|
|
||||||
const info = batchToolbar._extractInfo(/** @type {HTMLDivElement} */ (card));
|
|
||||||
if (info) batchToolbar.deselect(info.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener('mouseup', () => {
|
document.addEventListener('mouseup', () => {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
active = false;
|
active = false;
|
||||||
|
document.removeEventListener('scroll', invalidateCardCache, { capture: true });
|
||||||
|
// Apply the still-pending classification so the final lasso
|
||||||
|
// position is what determines the selection.
|
||||||
|
if (rafId) {
|
||||||
|
cancelAnimationFrame(rafId);
|
||||||
|
classifyCards();
|
||||||
|
}
|
||||||
|
cardCache = null;
|
||||||
const hadSelection = selRect.style.display === 'block';
|
const hadSelection = selRect.style.display === 'block';
|
||||||
selRect.style.display = 'none';
|
selRect.style.display = 'none';
|
||||||
// Update the batch bar after rubber band selection completes
|
// Update the batch bar after rubber band selection completes
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { loadFiles } from '../../app/filesView.js';
|
|||||||
import { app } from '../../app/state.js';
|
import { app } from '../../app/state.js';
|
||||||
import { showConfirmDialog, ui } from '../../app/ui.js';
|
import { showConfirmDialog, ui } from '../../app/ui.js';
|
||||||
import { i18n } from '../../core/i18n.js';
|
import { i18n } from '../../core/i18n.js';
|
||||||
|
import { buildBatchDownloadUrl, triggerBrowserDownload } from '../../utils/download.js';
|
||||||
import { favorites } from '../library/favorites.js';
|
import { favorites } from '../library/favorites.js';
|
||||||
import { contextMenus } from './contextMenus.js';
|
import { contextMenus } from './contextMenus.js';
|
||||||
import { getAuthHeaders } from './fileOperations.js';
|
import { getAuthHeaders } from './fileOperations.js';
|
||||||
@@ -126,10 +127,14 @@ const batchToolbar = {
|
|||||||
clear() {
|
clear() {
|
||||||
this._selected.clear();
|
this._selected.clear();
|
||||||
this._lastClickedIndex = -1;
|
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');
|
el.classList.remove('selected');
|
||||||
});
|
});
|
||||||
document.querySelectorAll('.item-checkbox').forEach((cb) => {
|
list?.querySelectorAll('.item-checkbox:checked').forEach((cb) => {
|
||||||
/** @type {HTMLInputElement} */ (cb).checked = false;
|
/** @type {HTMLInputElement} */ (cb).checked = false;
|
||||||
});
|
});
|
||||||
// Reset the active component's internal selection state without going
|
// Reset the active component's internal selection state without going
|
||||||
@@ -173,15 +178,16 @@ const batchToolbar = {
|
|||||||
/** @type {Array<string>} */
|
/** @type {Array<string>} */
|
||||||
const folderIds = [];
|
const folderIds = [];
|
||||||
|
|
||||||
// TODO optimize & check if _selected is a better use
|
// `_selected` is the source of truth (every selection path keeps it
|
||||||
/** @type {NodeListOf<HTMLDivElement>} */ (document.querySelectorAll(`div.file-item.selected`)).forEach((item) => {
|
// in sync) — no need to re-derive the selection from a DOM scan.
|
||||||
if (item.dataset.fileId) {
|
for (const sel of this._selected.values()) {
|
||||||
fileIds.push(item.dataset.fileId);
|
if (sel.type === 'file') {
|
||||||
} else {
|
fileIds.push(sel.id);
|
||||||
// ignore selectedItem if this is the target
|
} else if (targtFolderId && targtFolderId !== sel.id) {
|
||||||
if (targtFolderId && targtFolderId !== item.dataset.folderId) folderIds.push(item.dataset.folderId);
|
// ignore the selected folder if it is the drop target itself
|
||||||
|
folderIds.push(sel.id);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
fileIds: fileIds,
|
fileIds: fileIds,
|
||||||
@@ -454,10 +460,22 @@ const batchToolbar = {
|
|||||||
|
|
||||||
ui.showNotification('Preparing download', 'Creating ZIP archive...');
|
ui.showNotification('Preparing download', 'Creating ZIP archive...');
|
||||||
|
|
||||||
try {
|
const fileIds = items.filter((i) => i.type === 'file').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 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', {
|
const response = await fetch('/api/batch/download', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' },
|
headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' },
|
||||||
@@ -467,14 +485,14 @@ const batchToolbar = {
|
|||||||
if (!response.ok) throw new Error(`Server returned ${response.status}`);
|
if (!response.ok) throw new Error(`Server returned ${response.status}`);
|
||||||
|
|
||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
const url = URL.createObjectURL(blob);
|
const blobUrl = URL.createObjectURL(blob);
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.href = url;
|
link.href = blobUrl;
|
||||||
link.download = `oxicloud-download-${Date.now()}.zip`;
|
link.download = zipName;
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
document.body.removeChild(link);
|
document.body.removeChild(link);
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(blobUrl);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Batch download error:', e);
|
console.error('Batch download error:', e);
|
||||||
ui.showNotification('Error', 'Could not download selected items');
|
ui.showNotification('Error', 'Could not download selected items');
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { showConfirmDialog, ui } from '../../app/ui.js';
|
|||||||
import { getCsrfHeaders, getCsrfToken } from '../../core/csrf.js';
|
import { getCsrfHeaders, getCsrfToken } from '../../core/csrf.js';
|
||||||
import { i18n } from '../../core/i18n.js';
|
import { i18n } from '../../core/i18n.js';
|
||||||
import { notifications } from '../../core/notifications.js';
|
import { notifications } from '../../core/notifications.js';
|
||||||
|
import { triggerBrowserDownload } from '../../utils/download.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} BatchResult
|
* @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} fileId - File ID
|
||||||
* @param {string} fileName - File name
|
* @param {string} fileName - File name
|
||||||
*/
|
*/
|
||||||
async downloadFile(fileId, fileName) {
|
async downloadFile(fileId, fileName) {
|
||||||
try {
|
triggerBrowserDownload(`/api/files/${fileId}`, fileName);
|
||||||
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');
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1403,30 +1385,10 @@ const fileOps = {
|
|||||||
* @param {string} folderName - Folder name
|
* @param {string} folderName - Folder name
|
||||||
*/
|
*/
|
||||||
async downloadFolder(folderId, folderName) {
|
async downloadFolder(folderId, folderName) {
|
||||||
try {
|
// Show notification to user (the server still has to assemble the
|
||||||
// Show notification to user
|
// ZIP before the browser's own download UI takes over).
|
||||||
ui.showNotification('Preparing download', 'Preparing the folder for download...');
|
ui.showNotification('Preparing download', 'Preparing the folder for download...');
|
||||||
|
triggerBrowserDownload(`/api/folders/${folderId}/download?format=zip`, `${folderName}.zip`);
|
||||||
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');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import { updateHistory } from '../../app/main.js';
|
import { updateHistory } from '../../app/main.js';
|
||||||
import { app } from '../../app/state.js';
|
import { app } from '../../app/state.js';
|
||||||
import { isTextViewable } from '../../core/formatters.js';
|
import { isTextViewable } from '../../core/formatters.js';
|
||||||
|
import { triggerBrowserDownload } from '../../utils/download.js';
|
||||||
import { wopiEditor } from './wopiEditor.js';
|
import { wopiEditor } from './wopiEditor.js';
|
||||||
|
|
||||||
/** @import {FileItem} from '../../core/types.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 {FileItem} file
|
||||||
* @param {string} mediaType
|
* @param {string} mediaType
|
||||||
* @param {HTMLDivElement} container
|
* @param {HTMLDivElement} container
|
||||||
* @param {HTMLDivElement} loader
|
* @param {HTMLDivElement} loader
|
||||||
*/
|
*/
|
||||||
async createMediaViewer(file, mediaType, container, loader) {
|
createMediaViewer(file, mediaType, container, loader) {
|
||||||
try {
|
console.log(`Creating ${mediaType} player for:`, file.name);
|
||||||
console.log(`Creating ${mediaType} player for:`, file.name);
|
|
||||||
|
|
||||||
// Fetch file (cookie auto-sent)
|
const streamUrl = `/api/files/${file.id}?inline=true`;
|
||||||
const response = await fetch(`/api/files/${file.id}?inline=true`, {
|
|
||||||
credentials: 'same-origin'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
// The native player has its own buffering UI — drop our spinner now.
|
||||||
throw new Error(`Error fetching file: ${response.status} ${response.statusText}`);
|
if (loader?.parentNode) {
|
||||||
}
|
loader.parentNode.removeChild(loader);
|
||||||
|
}
|
||||||
|
|
||||||
const blob = await response.blob();
|
if (mediaType === 'audio') {
|
||||||
const blobUrl = URL.createObjectURL(blob);
|
// Wrapper with icon + player
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.className = 'inline-viewer-audio-wrapper';
|
||||||
|
|
||||||
// Remove loader
|
const icon = document.createElement('div');
|
||||||
if (loader?.parentNode) {
|
icon.className = 'inline-viewer-audio-icon';
|
||||||
loader.parentNode.removeChild(loader);
|
icon.innerHTML = '<i class="fas fa-music"></i>';
|
||||||
}
|
wrapper.appendChild(icon);
|
||||||
|
|
||||||
if (mediaType === 'audio') {
|
const nameEl = document.createElement('div');
|
||||||
// Wrapper with icon + player
|
nameEl.className = 'inline-viewer-audio-name';
|
||||||
const wrapper = document.createElement('div');
|
nameEl.textContent = file.name;
|
||||||
wrapper.className = 'inline-viewer-audio-wrapper';
|
wrapper.appendChild(nameEl);
|
||||||
|
|
||||||
const icon = document.createElement('div');
|
const audio = document.createElement('audio');
|
||||||
icon.className = 'inline-viewer-audio-icon';
|
audio.className = 'inline-viewer-audio';
|
||||||
icon.innerHTML = '<i class="fas fa-music"></i>';
|
audio.controls = true;
|
||||||
wrapper.appendChild(icon);
|
audio.preload = 'metadata';
|
||||||
|
audio.src = streamUrl;
|
||||||
|
wrapper.appendChild(audio);
|
||||||
|
|
||||||
const nameEl = document.createElement('div');
|
// Fallback message for unsupported codecs / failed loads
|
||||||
nameEl.className = 'inline-viewer-audio-name';
|
audio.addEventListener('error', () => {
|
||||||
nameEl.textContent = file.name;
|
console.warn('Audio playback error — codec may not be supported');
|
||||||
wrapper.appendChild(nameEl);
|
wrapper.innerHTML = '';
|
||||||
|
const msg = document.createElement('div');
|
||||||
const audio = document.createElement('audio');
|
msg.className = 'inline-viewer-message';
|
||||||
audio.className = 'inline-viewer-audio';
|
msg.innerHTML = `
|
||||||
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 = `
|
|
||||||
<div class="inline-viewer-icon"><i class="fas fa-exclamation-circle"></i></div>
|
<div class="inline-viewer-icon"><i class="fas fa-exclamation-circle"></i></div>
|
||||||
<div class="inline-viewer-text">
|
<div class="inline-viewer-text">
|
||||||
<p>Your browser cannot play this audio format.</p>
|
<p>Your browser cannot play this audio format.</p>
|
||||||
<p>Click "Download" to save the file.</p>
|
<p>Click "Download" to save the file.</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
wrapper.appendChild(msg);
|
wrapper.appendChild(msg);
|
||||||
});
|
});
|
||||||
|
|
||||||
container.appendChild(wrapper);
|
container.appendChild(wrapper);
|
||||||
} else {
|
} else {
|
||||||
const video = document.createElement('video');
|
const video = document.createElement('video');
|
||||||
video.className = 'inline-viewer-video';
|
video.className = 'inline-viewer-video';
|
||||||
video.controls = true;
|
video.controls = true;
|
||||||
video.preload = 'metadata';
|
video.preload = 'metadata';
|
||||||
video.src = blobUrl;
|
video.src = streamUrl;
|
||||||
video.setAttribute('playsinline', 'true');
|
video.setAttribute('playsinline', 'true');
|
||||||
|
|
||||||
// Fallback message for unsupported codecs
|
// Fallback message for unsupported codecs / failed loads
|
||||||
video.addEventListener('error', () => {
|
video.addEventListener('error', () => {
|
||||||
console.warn('Video playback error — codec may not be supported');
|
console.warn('Video playback error — codec may not be supported');
|
||||||
if (video.parentNode) {
|
if (video.parentNode) {
|
||||||
video.parentNode.removeChild(video);
|
video.parentNode.removeChild(video);
|
||||||
}
|
}
|
||||||
const msg = document.createElement('div');
|
const msg = document.createElement('div');
|
||||||
msg.className = 'inline-viewer-message';
|
msg.className = 'inline-viewer-message';
|
||||||
msg.innerHTML = `
|
msg.innerHTML = `
|
||||||
<div class="inline-viewer-icon"><i class="fas fa-exclamation-circle"></i></div>
|
<div class="inline-viewer-icon"><i class="fas fa-exclamation-circle"></i></div>
|
||||||
<div class="inline-viewer-text">
|
<div class="inline-viewer-text">
|
||||||
<p>Your browser cannot play this video format.</p>
|
<p>Your browser cannot play this video format.</p>
|
||||||
<p>Click "Download" to save the file.</p>
|
<p>Click "Download" to save the file.</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
container.appendChild(msg);
|
container.appendChild(msg);
|
||||||
});
|
});
|
||||||
|
|
||||||
container.appendChild(video);
|
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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
|
* @param {FileItem} file
|
||||||
*/
|
*/
|
||||||
downloadFile(file) {
|
downloadFile(file) {
|
||||||
fetch(`/api/files/${file.id}`, { credentials: 'same-origin' })
|
triggerBrowserDownload(`/api/files/${file.id}`, file.name);
|
||||||
.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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// @ts-check
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Browser-native download helpers.
|
||||||
|
*
|
||||||
|
* Downloads are handed to the browser as same-origin navigations: the
|
||||||
|
* response streams straight to disk with the browser's own progress UI,
|
||||||
|
* and auth cookies travel automatically. Nothing is buffered in page
|
||||||
|
* memory — unlike the old `fetch → blob → objectURL` pattern, which
|
||||||
|
* materialized the entire payload in the tab's heap before the save
|
||||||
|
* dialog could even appear.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger a browser-native download for a same-origin URL.
|
||||||
|
*
|
||||||
|
* @param {string} url - Same-origin URL of the resource to download
|
||||||
|
* @param {string} [filename] - Suggested file name. The server's
|
||||||
|
* `Content-Disposition` filename wins when present; an empty string
|
||||||
|
* keeps whatever the server (or URL) provides.
|
||||||
|
*/
|
||||||
|
export function triggerBrowserDownload(url, filename = '') {
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the GET URL for the batch ZIP download endpoint
|
||||||
|
* (`GET /api/batch/download` accepts comma-separated id lists).
|
||||||
|
* Shared by the batch toolbar download and the drag-out `DownloadURL`
|
||||||
|
* builder so both stay in sync with the endpoint's query contract.
|
||||||
|
*
|
||||||
|
* @param {string[]} fileIds
|
||||||
|
* @param {string[]} folderIds
|
||||||
|
* @returns {string} Root-relative URL (prepend `window.location.origin`
|
||||||
|
* when an absolute URL is required, e.g. for `DataTransfer.setData`).
|
||||||
|
*/
|
||||||
|
export function buildBatchDownloadUrl(fileIds, folderIds) {
|
||||||
|
return `/api/batch/download?file_ids=${fileIds.join(',')}&folder_ids=${folderIds.join(',')}`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user