diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2182066..56c1f46 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,29 @@
# Changelog
-## changes 11/18/2025 (v1.9.10)
+## Changes 11/18/2025 (v1.9.11)
+
+release(v1.9.11): fix(media): HTTP Range streaming; feat(ui): paged folder strip (closes #68)
+
+- media: add proper HTTP Range support to /api/file/download.php so HTML5
+ video/audio can seek correctly across all browsers (Brave/Chrome/Android/Windows).
+- media: avoid buffering the entire file in memory; stream from disk with
+ 200/206 responses and Accept-Ranges for smoother playback and faster start times.
+- media: keep video progress tracking, watched badges, and status chip behavior
+ unchanged but now compatible with the new streaming endpoint.
+
+- ui: update the folder strip to be responsive:
+ - desktop: keep the existing "chip" layout with icon above name.
+ - mobile: switch to inline rows `[icon] [name]` with reduced whitespace.
+- ui: add simple lazy-loading for the folder strip so only the first batch of
+ folders is rendered initially, with a "Load more…" button to append chunks for
+ very large folder sets (stays friendly with 100k+ folders).
+
+- misc: small CSS tidy-up around the folder strip classes to remove duplicates
+ and keep mobile/desktop behavior clearly separated.
+
+---
+
+## Changes 11/18/2025 (v1.9.10)
release(v1.9.10): add Pro bundle installer and admin panel polish
diff --git a/public/css/styles.css b/public/css/styles.css
index 7e0ec1f..303aa88 100644
--- a/public/css/styles.css
+++ b/public/css/styles.css
@@ -1887,4 +1887,94 @@ body.dark-mode #folderTreeContainer .folder-icon .lock-keyhole{fill: rgba(255,25
color: #111;}
.dark-mode .upload-resume-banner-inner .material-icons,
.dark-mode .folder-badge .material-icons{background-color: transparent;
- color: #f5f5f5;}
\ No newline at end of file
+ color: #f5f5f5;}
+ /* Base strip container */
+.folder-strip-container {
+ margin-bottom: 6px;
+}
+
+/* Base item layout */
+.folder-strip-container .folder-item {
+ display: flex;
+ min-width: 0;
+}
+
+.folder-strip-container .folder-svg {
+ flex: 0 0 auto;
+ line-height: 0;
+}
+
+.folder-strip-container .folder-name {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+/* --- Desktop: chips, icon above name --- */
+.folder-strip-container.folder-strip-desktop {
+ display: flex;
+ align-items: center;
+ overflow-x: auto;
+ padding: 4px 8px;
+}
+
+.folder-strip-container.folder-strip-desktop .folder-item {
+ flex-direction: column; /* icon on top, name under */
+ align-items: center;
+ gap: 4px;
+}
+
+.folder-strip-container.folder-strip-desktop .folder-name {
+ text-align: center;
+ max-width: 120px;
+}
+
+/* --- Mobile: stacked rows, icon left of name --- */
+.folder-strip-container.folder-strip-mobile {
+ display: block;
+ max-height: 220px;
+ overflow-y: auto;
+ padding: 6px 8px;
+ border-radius: 8px;
+ border: 1px solid rgba(0,0,0,.08);
+ background: rgba(0,0,0,.02);
+}
+
+.folder-strip-container.folder-strip-mobile .folder-item {
+ width: 100%;
+ flex-direction: row; /* icon left, name right */
+ align-items: center;
+ gap: 6px;
+ padding: 8px 8px;
+ border-radius: 6px;
+ margin-bottom: 4px;
+}
+
+.folder-strip-container.folder-strip-mobile .folder-name {
+ flex: 1 1 auto;
+ text-align: left;
+ transform: translate(8px, 4px);
+
+}
+
+.folder-strip-container.folder-strip-mobile .folder-item:hover {
+ background: rgba(0,0,0,.04);
+}
+
+.folder-strip-container.folder-strip-mobile .folder-item.selected {
+ background: rgba(59,130,246,.12);
+}
+
+/* Load-more button */
+.folder-strip-load-more {
+ display: block;
+ width: 100%;
+ margin: 4px 0 0;
+ padding: 6px 10px;
+ border-radius: 6px;
+ border: 1px solid rgba(0,0,0,.15);
+ background: rgba(0,0,0,.02);
+ font-size: 0.85rem;
+ text-align: center;
+ cursor: pointer;
+}
\ No newline at end of file
diff --git a/public/js/fileListView.js b/public/js/fileListView.js
index 8356e78..ee9aeed 100644
--- a/public/js/fileListView.js
+++ b/public/js/fileListView.js
@@ -40,7 +40,7 @@ export let fileData = [];
export let sortOrder = { column: "uploaded", ascending: true };
-
+const FOLDER_STRIP_PAGE_SIZE = 50;
// onnlyoffice
let OO_ENABLED = false;
let OO_EXTS = new Set();
@@ -58,6 +58,143 @@ export async function initOnlyOfficeCaps() {
}
}
+function wireFolderStripItems(strip) {
+ if (!strip) return;
+
+ // Click / DnD / context menu
+ strip.querySelectorAll(".folder-item").forEach(el => {
+ // 1) click to navigate
+ el.addEventListener("click", () => {
+ const dest = el.dataset.folder;
+ if (!dest) return;
+
+ window.currentFolder = dest;
+ localStorage.setItem("lastOpenedFolder", dest);
+ updateBreadcrumbTitle(dest);
+
+ document.querySelectorAll(".folder-option.selected")
+ .forEach(o => o.classList.remove("selected"));
+ document
+ .querySelector(`.folder-option[data-folder="${dest}"]`)
+ ?.classList.add("selected");
+
+ loadFileList(dest);
+ });
+
+ // 2) drag & drop
+ el.addEventListener("dragover", folderDragOverHandler);
+ el.addEventListener("dragleave", folderDragLeaveHandler);
+ el.addEventListener("drop", folderDropHandler);
+
+ // 3) right-click context menu
+ el.addEventListener("contextmenu", e => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ const dest = el.dataset.folder;
+ if (!dest) return;
+
+ window.currentFolder = dest;
+ localStorage.setItem("lastOpenedFolder", dest);
+
+ strip.querySelectorAll(".folder-item.selected")
+ .forEach(i => i.classList.remove("selected"));
+ el.classList.add("selected");
+
+ const menuItems = [
+ {
+ label: t("create_folder"),
+ action: () => document.getElementById("createFolderModal").style.display = "block"
+ },
+ {
+ label: t("move_folder"),
+ action: () => openMoveFolderUI()
+ },
+ {
+ label: t("rename_folder"),
+ action: () => openRenameFolderModal()
+ },
+ {
+ label: t("color_folder"),
+ action: () => openColorFolderModal(dest)
+ },
+ {
+ label: t("folder_share"),
+ action: () => openFolderShareModal(dest)
+ },
+ {
+ label: t("delete_folder"),
+ action: () => openDeleteFolderModal()
+ }
+ ];
+ showFolderManagerContextMenu(e.pageX, e.pageY, menuItems);
+ });
+ });
+
+ // Close menu when clicking elsewhere
+ document.addEventListener("click", hideFolderManagerContextMenu);
+
+ // Folder icons
+ strip.querySelectorAll(".folder-item").forEach(el => {
+ const full = el.getAttribute('data-folder');
+ if (full) attachStripIconAsync(el, full, 48);
+ });
+}
+
+function renderFolderStripPaged(strip, subfolders) {
+ if (!strip) return;
+
+ if (!window.showFoldersInList || !subfolders.length) {
+ strip.style.display = "none";
+ strip.innerHTML = "";
+ return;
+ }
+
+ const total = subfolders.length;
+ const pageSize = FOLDER_STRIP_PAGE_SIZE;
+ const totalPages = Math.ceil(total / pageSize);
+
+ function drawPage(page) {
+ const endIdx = Math.min(page * pageSize, total);
+ const visible = subfolders.slice(0, endIdx);
+
+ let html = visible.map(sf => `
+
+
+
+ ${escapeHTML(sf.name)}
+
+
+ `).join("");
+
+ if (endIdx < total) {
+ html += `
+
+ `;
+ }
+
+ strip.innerHTML = html;
+
+ applyFolderStripLayout(strip);
+ wireFolderStripItems(strip);
+
+ const loadMoreBtn = strip.querySelector(".folder-strip-load-more");
+ if (loadMoreBtn) {
+ loadMoreBtn.addEventListener("click", e => {
+ e.preventDefault();
+ e.stopPropagation();
+ drawPage(page + 1);
+ });
+ }
+ }
+
+ drawPage(1);
+}
// helper to repaint one strip item quickly
function repaintStripIcon(folder) {
@@ -78,6 +215,31 @@ function repaintStripIcon(folder) {
iconSpan.innerHTML = folderSVG(kind);
}
+function applyFolderStripLayout(strip) {
+ if (!strip) return;
+ const hasItems = strip.querySelector('.folder-item') !== null;
+ if (!hasItems) {
+ strip.style.display = 'none';
+ strip.classList.remove('folder-strip-mobile', 'folder-strip-desktop');
+ return;
+ }
+
+ const isMobile = window.innerWidth <= 640; // tweak breakpoint if you want
+
+ strip.classList.add('folder-strip-container');
+ strip.classList.toggle('folder-strip-mobile', isMobile);
+ strip.classList.toggle('folder-strip-desktop', !isMobile);
+
+ strip.style.display = isMobile ? 'block' : 'flex';
+ strip.style.overflowX = isMobile ? 'visible' : 'auto';
+ strip.style.overflowY = isMobile ? 'auto' : 'hidden';
+}
+
+window.addEventListener('resize', () => {
+ const strip = document.getElementById('folderStripContainer');
+ if (strip) applyFolderStripLayout(strip);
+});
+
// Listen once: update strip + tree when folder color changes
window.addEventListener('folderColorChanged', (e) => {
const { folder } = e.detail || {};
@@ -812,93 +974,8 @@ export async function loadFileList(folderParam) {
actionsContainer.parentNode.insertBefore(strip, actionsContainer);
}
- if (window.showFoldersInList && subfolders.length) {
- strip.innerHTML = subfolders.map(sf => {
- return `
-
-
-
- ${escapeHTML(sf.name)}
-
-
- `;
- }).join("");
- strip.style.display = "flex";
-
- strip.querySelectorAll(".folder-item").forEach(el => {
- // 1) click to navigate
- el.addEventListener("click", () => {
- const dest = el.dataset.folder;
- window.currentFolder = dest;
- localStorage.setItem("lastOpenedFolder", dest);
- updateBreadcrumbTitle(dest);
- document.querySelectorAll(".folder-option.selected").forEach(o => o.classList.remove("selected"));
- document.querySelector(`.folder-option[data-folder="${dest}"]`)?.classList.add("selected");
- loadFileList(dest);
- });
-
- // 2) drag & drop
- el.addEventListener("dragover", folderDragOverHandler);
- el.addEventListener("dragleave", folderDragLeaveHandler);
- el.addEventListener("drop", folderDropHandler);
-
- // 3) right-click context menu
- el.addEventListener("contextmenu", e => {
- e.preventDefault();
- e.stopPropagation();
-
- const dest = el.dataset.folder;
- window.currentFolder = dest;
- localStorage.setItem("lastOpenedFolder", dest);
-
- strip.querySelectorAll(".folder-item.selected").forEach(i => i.classList.remove("selected"));
- el.classList.add("selected");
-
- const menuItems = [
- {
- label: t("create_folder"),
- action: () => document.getElementById("createFolderModal").style.display = "block"
- },
- {
- label: t("move_folder"),
- action: () => openMoveFolderUI()
- },
- {
- label: t("rename_folder"),
- action: () => openRenameFolderModal()
- },
- {
- label: t("color_folder"),
- action: () => openColorFolderModal(dest)
- },
- {
- label: t("folder_share"),
- action: () => openFolderShareModal(dest)
- },
- {
- label: t("delete_folder"),
- action: () => openDeleteFolderModal()
- }
- ];
- showFolderManagerContextMenu(e.pageX, e.pageY, menuItems);
- });
- });
-
- document.addEventListener("click", hideFolderManagerContextMenu);
-
- // After wiring events for each .folder-item:
- strip.querySelectorAll(".folder-item").forEach(el => {
- const full = el.getAttribute('data-folder');
- attachStripIconAsync(el, full, 48);
- });
-
- } else {
- strip.style.display = "none";
- }
+ // NEW: paged + responsive strip
+ renderFolderStripPaged(strip, subfolders);
} catch {
// ignore folder errors; rows already rendered
}
diff --git a/public/js/filePreview.js b/public/js/filePreview.js
index ceac99f..aedaa17 100644
--- a/public/js/filePreview.js
+++ b/public/js/filePreview.js
@@ -469,102 +469,118 @@ export function previewFile(fileUrl, fileName) {
return;
}
- /* -------------------- VIDEOS -------------------- */
- if (isVideo) {
- let video = document.createElement("video"); // let so we can rebind
- video.controls = true;
- video.style.maxWidth = "88vw";
- video.style.maxHeight = "88vh";
- video.style.objectFit = "contain";
- container.appendChild(video);
-
- // Top-right action icons (Material icons, theme-aware)
- const markBtnIcon = makeTopIcon('check_circle', t("mark_as_viewed") || "Mark as viewed");
- const clearBtnIcon = makeTopIcon('restart_alt', t("clear_progress") || "Clear progress");
- actionWrap.appendChild(markBtnIcon);
- actionWrap.appendChild(clearBtnIcon);
-
- const videos = (Array.isArray(fileData) ? fileData : []).filter(f => VID_RE.test(f.name));
- overlay.mediaType = 'video';
- overlay.mediaList = videos;
- overlay.mediaIndex = Math.max(0, videos.findIndex(f => f.name === name));
- setNavVisibility(overlay, videos.length > 1, videos.length > 1);
-
- const setVideoSrc = (nm) => { video.src = buildPreviewUrl(folder, nm); setTitle(overlay, nm); };
-
- const SAVE_INTERVAL_MS = 5000;
- let lastSaveAt = 0;
- let pending = false;
-
- async function getProgress(nm) {
- try {
- const res = await fetch(`/api/media/getProgress.php?folder=${encodeURIComponent(folder)}&file=${encodeURIComponent(nm)}&t=${Date.now()}`, { credentials: "include" });
- const data = await res.json();
- return data && data.state ? data.state : null;
- } catch { return null; }
- }
- async function sendProgress({nm, seconds, duration, completed, clear}) {
- try {
- pending = true;
- const res = await fetch("/api/media/updateProgress.php", {
- method: "POST",
- credentials: "include",
- headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
- body: JSON.stringify({ folder, file: nm, seconds, duration, completed, clear })
- });
- const data = await res.json();
- pending = false;
- return data;
- } catch (e) { pending = false; console.error(e); return null; }
- }
- const lsKey = (nm) => `videoProgress-${folder}/${nm}`;
-
- function renderStatus(state) {
- if (!statusChip) return;
- // Completed
- if (state && state.completed) {
- statusChip.textContent = (t('viewed') || 'Viewed') + ' ✓';
- statusChip.style.display = 'inline-block';
- statusChip.style.borderColor = 'rgba(34,197,94,.45)';
- statusChip.style.background = 'rgba(34,197,94,.15)';
- statusChip.style.color = '#22c55e';
- markBtnIcon.style.display = 'none';
- clearBtnIcon.style.display = '';
- clearBtnIcon.title = t('reset_progress') || t('clear_progress') || 'Reset';
- return;
+ /* -------------------- VIDEOS -------------------- */
+ if (isVideo) {
+ let video = document.createElement("video");
+ video.controls = true;
+ video.preload = 'auto'; // hint browser to start fetching quickly
+ video.style.maxWidth = "88vw";
+ video.style.maxHeight = "88vh";
+ video.style.objectFit = "contain";
+ container.appendChild(video);
+
+ // Top-right action icons (Material icons, theme-aware)
+ const markBtnIcon = makeTopIcon('check_circle', t("mark_as_viewed") || "Mark as viewed");
+ const clearBtnIcon = makeTopIcon('restart_alt', t("clear_progress") || "Clear progress");
+ actionWrap.appendChild(markBtnIcon);
+ actionWrap.appendChild(clearBtnIcon);
+
+ const videos = (Array.isArray(fileData) ? fileData : []).filter(f => VID_RE.test(f.name));
+ overlay.mediaType = 'video';
+ overlay.mediaList = videos;
+ overlay.mediaIndex = Math.max(0, videos.findIndex(f => f.name === name));
+ setNavVisibility(overlay, videos.length > 1, videos.length > 1);
+
+ // Track which file is currently active
+ let currentName = name;
+
+ const setVideoSrc = (nm) => {
+ currentName = nm;
+ video.src = buildPreviewUrl(folder, nm);
+ setTitle(overlay, nm);
+ };
+
+ const SAVE_INTERVAL_MS = 5000;
+ let lastSaveAt = 0;
+ let pending = false;
+
+ async function getProgress(nm) {
+ try {
+ const res = await fetch(`/api/media/getProgress.php?folder=${encodeURIComponent(folder)}&file=${encodeURIComponent(nm)}&t=${Date.now()}`, { credentials: "include" });
+ const data = await res.json();
+ return data && data.state ? data.state : null;
+ } catch { return null; }
}
- // In progress
- if (state && Number.isFinite(state.seconds) && Number.isFinite(state.duration) && state.duration > 0) {
- const pct = Math.max(1, Math.min(99, Math.round((state.seconds / state.duration) * 100)));
- statusChip.textContent = `${pct}%`;
- statusChip.style.display = 'inline-block';
- const dark = document.documentElement.classList.contains('dark-mode');
- const ORANGE_HEX = '#ea580c'; // darker orange (works in light/dark)
- statusChip.style.color = ORANGE_HEX;
- statusChip.style.borderColor = dark ? 'rgba(234,88,12,.55)' : 'rgba(234,88,12,.45)'; // #ea580c @ different alphas
- statusChip.style.background = dark ? 'rgba(234,88,12,.18)' : 'rgba(234,88,12,.12)';
+
+ async function sendProgress({nm, seconds, duration, completed, clear}) {
+ try {
+ pending = true;
+ const res = await fetch("/api/media/updateProgress.php", {
+ method: "POST",
+ credentials: "include",
+ headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
+ body: JSON.stringify({ folder, file: nm, seconds, duration, completed, clear })
+ });
+ const data = await res.json();
+ pending = false;
+ return data;
+ } catch (e) {
+ pending = false;
+ console.error(e);
+ return null;
+ }
+ }
+
+ const lsKey = (nm) => `videoProgress-${folder}/${nm}`;
+
+ function renderStatus(state) {
+ if (!statusChip) return;
+
+ // Completed
+ if (state && state.completed) {
+ statusChip.textContent = (t('viewed') || 'Viewed') + ' ✓';
+ statusChip.style.display = 'inline-block';
+ statusChip.style.borderColor = 'rgba(34,197,94,.45)';
+ statusChip.style.background = 'rgba(34,197,94,.15)';
+ statusChip.style.color = '#22c55e';
+ markBtnIcon.style.display = 'none';
+ clearBtnIcon.style.display = '';
+ clearBtnIcon.title = t('reset_progress') || t('clear_progress') || 'Reset';
+ return;
+ }
+
+ // In progress
+ if (state && Number.isFinite(state.seconds) && Number.isFinite(state.duration) && state.duration > 0) {
+ const pct = Math.max(1, Math.min(99, Math.round((state.seconds / state.duration) * 100)));
+ statusChip.textContent = `${pct}%`;
+ statusChip.style.display = 'inline-block';
+
+ const dark = document.documentElement.classList.contains('dark-mode');
+ const ORANGE_HEX = '#ea580c';
+ statusChip.style.color = ORANGE_HEX;
+ statusChip.style.borderColor = dark ? 'rgba(234,88,12,.55)' : 'rgba(234,88,12,.45)';
+ statusChip.style.background = dark ? 'rgba(234,88,12,.18)' : 'rgba(234,88,12,.12)';
+
+ markBtnIcon.style.display = '';
+ clearBtnIcon.style.display = '';
+ clearBtnIcon.title = t('reset_progress') || t('clear_progress') || 'Reset';
+ return;
+ }
+
+ // No progress
+ statusChip.style.display = 'none';
markBtnIcon.style.display = '';
- clearBtnIcon.style.display = '';
- clearBtnIcon.title = t('reset_progress') || t('clear_progress') || 'Reset';
- return;
+ clearBtnIcon.style.display = 'none';
}
- // No progress
- statusChip.style.display = 'none';
- markBtnIcon.style.display = '';
- clearBtnIcon.style.display = 'none';
- }
-
- function bindVideoEvents(nm) {
- const nv = video.cloneNode(true);
- video.replaceWith(nv);
- video = nv;
-
+
+ // ---- Event handlers (use currentName instead of rebinding per file) ----
video.addEventListener("loadedmetadata", async () => {
+ const nm = currentName;
try {
const state = await getProgress(nm);
if (state && Number.isFinite(state.seconds) && state.seconds > 0 && state.seconds < (video.duration || Infinity)) {
video.currentTime = state.seconds;
- const seconds = Math.floor(video.currentTime || 0);
+ const seconds = Math.floor(video.currentTime || 0);
const duration = Math.floor(video.duration || 0);
setFileProgressBadge(nm, seconds, duration);
showToast((t("resumed_from") || "Resumed from") + " " + Math.floor(state.seconds) + "s");
@@ -577,20 +593,24 @@ export function previewFile(fileUrl, fileName) {
renderStatus(null);
}
});
-
+
video.addEventListener("timeupdate", async () => {
const now = Date.now();
if ((now - lastSaveAt) < SAVE_INTERVAL_MS || pending) return;
lastSaveAt = now;
- const seconds = Math.floor(video.currentTime || 0);
+
+ const nm = currentName;
+ const seconds = Math.floor(video.currentTime || 0);
const duration = Math.floor(video.duration || 0);
+
sendProgress({ nm, seconds, duration });
setFileProgressBadge(nm, seconds, duration);
try { localStorage.setItem(lsKey(nm), String(seconds)); } catch {}
renderStatus({ seconds, duration, completed: false });
});
-
+
video.addEventListener("ended", async () => {
+ const nm = currentName;
const duration = Math.floor(video.duration || 0);
await sendProgress({ nm, seconds: duration, duration, completed: true });
try { localStorage.removeItem(lsKey(nm)); } catch {}
@@ -598,50 +618,54 @@ export function previewFile(fileUrl, fileName) {
setFileWatchedBadge(nm, true);
renderStatus({ seconds: duration, duration, completed: true });
});
-
+
markBtnIcon.onclick = async () => {
+ const nm = currentName;
const duration = Math.floor(video.duration || 0);
await sendProgress({ nm, seconds: duration, duration, completed: true });
showToast(t("marked_viewed") || "Marked as viewed");
setFileWatchedBadge(nm, true);
renderStatus({ seconds: duration, duration, completed: true });
};
+
clearBtnIcon.onclick = async () => {
+ const nm = currentName;
await sendProgress({ nm, seconds: 0, duration: null, completed: false, clear: true });
try { localStorage.removeItem(lsKey(nm)); } catch {}
showToast(t("progress_cleared") || "Progress cleared");
setFileWatchedBadge(nm, false);
renderStatus(null);
};
- }
-
- const navigate = (dir) => {
- if (!overlay.mediaList || overlay.mediaList.length < 2) return;
- overlay.mediaIndex = (overlay.mediaIndex + dir + overlay.mediaList.length) % overlay.mediaList.length;
- const nm = overlay.mediaList[overlay.mediaIndex].name;
- setVideoSrc(nm);
- bindVideoEvents(nm);
- };
-
- if (videos.length > 1) {
- prevBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(-1); });
- nextBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(+1); });
- const onKey = (e) => {
- if (!document.body.contains(overlay)) { window.removeEventListener("keydown", onKey); return; }
- if (e.key === "ArrowLeft") navigate(-1);
- if (e.key === "ArrowRight") navigate(+1);
+
+ const navigate = (dir) => {
+ if (!overlay.mediaList || overlay.mediaList.length < 2) return;
+ overlay.mediaIndex = (overlay.mediaIndex + dir + overlay.mediaList.length) % overlay.mediaList.length;
+ const nm = overlay.mediaList[overlay.mediaIndex].name;
+ setVideoSrc(nm);
+ renderStatus(null);
};
- window.addEventListener("keydown", onKey);
- overlay._onKey = onKey;
+
+ if (videos.length > 1) {
+ prevBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(-1); });
+ nextBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(+1); });
+ const onKey = (e) => {
+ if (!document.body.contains(overlay)) {
+ window.removeEventListener("keydown", onKey);
+ return;
+ }
+ if (e.key === "ArrowLeft") navigate(-1);
+ if (e.key === "ArrowRight") navigate(+1);
+ };
+ window.addEventListener("keydown", onKey);
+ overlay._onKey = onKey;
+ }
+
+ setVideoSrc(name);
+ renderStatus(null);
+ overlay.style.display = "flex";
+ return;
}
- setVideoSrc(name);
- renderStatus(null);
- bindVideoEvents(name);
- overlay.style.display = "flex";
- return;
- }
-
/* -------------------- AUDIO / OTHER -------------------- */
if (isAudio) {
const audio = document.createElement("audio");
diff --git a/public/js/i18n.js b/public/js/i18n.js
index 8065059..6afc412 100644
--- a/public/js/i18n.js
+++ b/public/js/i18n.js
@@ -330,7 +330,8 @@ const translations = {
"folder_help_load_more": "For long lists, click “Load more” to fetch the next page of folders.",
"folder_help_last_folder": "Your last opened folder is remembered. If you lose access, we pick the first allowed folder automatically.",
"folder_help_breadcrumbs": "Use the breadcrumb to jump up the path. You can also drop onto a breadcrumb.",
- "folder_help_permissions": "Buttons enable/disable based on your permissions for the selected folder."
+ "folder_help_permissions": "Buttons enable/disable based on your permissions for the selected folder.",
+ "load_more_folders": "Load More Folders"
},
es: {
"please_log_in_to_continue": "Por favor, inicie sesión para continuar.",
diff --git a/src/controllers/FileController.php b/src/controllers/FileController.php
index ff2b123..8188eea 100644
--- a/src/controllers/FileController.php
+++ b/src/controllers/FileController.php
@@ -643,25 +643,137 @@ public function deleteFiles()
} finally { $this->_jsonEnd(); }
}
+ /**
+ * Stream a file with proper HTTP Range support so HTML5 video/audio can seek.
+ *
+ * @param string $fullPath Absolute filesystem path
+ * @param string $downloadName Name shown in Content-Disposition
+ * @param string $mimeType MIME type (from FileModel::getDownloadInfo)
+ * @param bool $inline true => inline, false => attachment
+ */
+ private function streamFileWithRange(string $fullPath, string $downloadName, string $mimeType, bool $inline): void
+ {
+ if (!is_file($fullPath) || !is_readable($fullPath)) {
+ http_response_code(404);
+ header('Content-Type: application/json; charset=utf-8');
+ echo json_encode(['error' => 'File not found']);
+ exit;
+ }
+
+ $size = (int)@filesize($fullPath);
+ $start = 0;
+ $end = $size > 0 ? $size - 1 : 0;
+
+ if ($size < 0) {
+ $size = 0;
+ $end = 0;
+ }
+
+ // Close session + disable output buffering for streaming
+ if (session_status() === PHP_SESSION_ACTIVE) {
+ @session_write_close();
+ }
+ if (function_exists('apache_setenv')) {
+ @apache_setenv('no-gzip', '1');
+ }
+ @ini_set('zlib.output_compression', '0');
+ @ini_set('output_buffering', 'off');
+ while (ob_get_level() > 0) {
+ @ob_end_clean();
+ }
+
+ $disposition = $inline ? 'inline' : 'attachment';
+ $mime = $mimeType ?: 'application/octet-stream';
+
+ header('X-Content-Type-Options: nosniff');
+ header('Accept-Ranges: bytes');
+ header("Content-Type: {$mime}");
+ header("Content-Disposition: {$disposition}; filename=\"" . basename($downloadName) . "\"");
+
+ // Handle HTTP Range header (single range)
+ $length = $size;
+ if (isset($_SERVER['HTTP_RANGE']) && preg_match('/bytes=\s*(\d*)-(\d*)/i', $_SERVER['HTTP_RANGE'], $m)) {
+ if ($m[1] !== '') {
+ $start = (int)$m[1];
+ }
+ if ($m[2] !== '') {
+ $end = (int)$m[2];
+ }
+
+ // clamp to file size
+ if ($start < 0) $start = 0;
+ if ($end < $start) $end = $start;
+ if ($end >= $size) $end = $size - 1;
+
+ $length = $end - $start + 1;
+
+ http_response_code(206);
+ header("Content-Range: bytes {$start}-{$end}/{$size}");
+ header("Content-Length: {$length}");
+ } else {
+ // no range => full file
+ http_response_code(200);
+ if ($size > 0) {
+ header("Content-Length: {$size}");
+ }
+ }
+
+ $fp = @fopen($fullPath, 'rb');
+ if ($fp === false) {
+ http_response_code(500);
+ header('Content-Type: application/json; charset=utf-8');
+ echo json_encode(['error' => 'Unable to open file.']);
+ exit;
+ }
+
+ if ($start > 0) {
+ @fseek($fp, $start);
+ }
+
+ $bytesToSend = $length;
+ $chunkSize = 8192;
+
+ while ($bytesToSend > 0 && !feof($fp)) {
+ $readSize = ($bytesToSend > $chunkSize) ? $chunkSize : $bytesToSend;
+ $buffer = fread($fp, $readSize);
+ if ($buffer === false) {
+ break;
+ }
+ echo $buffer;
+ flush();
+ $bytesToSend -= strlen($buffer);
+
+ if (connection_aborted()) {
+ break;
+ }
+ }
+
+ fclose($fp);
+ exit;
+ }
+
public function downloadFile()
{
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
http_response_code(401);
- header('Content-Type: application/json');
+ header('Content-Type: application/json; charset=utf-8');
echo json_encode(["error" => "Unauthorized"]);
exit;
}
- $file = isset($_GET['file']) ? basename($_GET['file']) : '';
- $folder = isset($_GET['folder']) ? trim($_GET['folder']) : 'root';
+ $file = isset($_GET['file']) ? basename((string)$_GET['file']) : '';
+ $folder = isset($_GET['folder']) ? trim((string)$_GET['folder']) : 'root';
+ $inlineParam = isset($_GET['inline']) && (string)$_GET['inline'] === '1';
if (!preg_match(REGEX_FILE_NAME, $file)) {
http_response_code(400);
+ header('Content-Type: application/json; charset=utf-8');
echo json_encode(["error" => "Invalid file name."]);
exit;
}
if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
http_response_code(400);
+ header('Content-Type: application/json; charset=utf-8');
echo json_encode(["error" => "Invalid folder name."]);
exit;
}
@@ -681,6 +793,7 @@ public function deleteFiles()
if (!$fullView && !$ownGrant) {
http_response_code(403);
+ header('Content-Type: application/json; charset=utf-8');
echo json_encode(["error" => "Forbidden: no view access to this folder."]);
exit;
}
@@ -690,6 +803,7 @@ public function deleteFiles()
$meta = $this->loadFolderMetadata($folder);
if (!isset($meta[$file]['uploader']) || strcasecmp((string)$meta[$file]['uploader'], $username) !== 0) {
http_response_code(403);
+ header('Content-Type: application/json; charset=utf-8');
echo json_encode(["error" => "Forbidden: you are not the owner of this file."]);
exit;
}
@@ -697,25 +811,25 @@ public function deleteFiles()
$downloadInfo = FileModel::getDownloadInfo($folder, $file);
if (isset($downloadInfo['error'])) {
- http_response_code((in_array($downloadInfo['error'], ["File not found.", "Access forbidden."])) ? 404 : 400);
+ http_response_code(in_array($downloadInfo['error'], ["File not found.", "Access forbidden."]) ? 404 : 400);
+ header('Content-Type: application/json; charset=utf-8');
echo json_encode(["error" => $downloadInfo['error']]);
exit;
}
$realFilePath = $downloadInfo['filePath'];
$mimeType = $downloadInfo['mimeType'];
- header("Content-Type: " . $mimeType);
+ // Decide inline vs attachment:
+ // - if ?inline=1 => always inline (used by filePreview.js)
+ // - else keep your old behavior: images inline, everything else attachment
$ext = strtolower(pathinfo($realFilePath, PATHINFO_EXTENSION));
$inlineImageTypes = ['jpg','jpeg','png','gif','bmp','webp','svg','ico'];
- if (in_array($ext, $inlineImageTypes, true)) {
- header('Content-Disposition: inline; filename="' . basename($realFilePath) . '"');
- } else {
- header('Content-Disposition: attachment; filename="' . basename($realFilePath) . '"');
- }
- header('Content-Length: ' . filesize($realFilePath));
- readfile($realFilePath);
- exit;
+
+ $inline = $inlineParam || in_array($ext, $inlineImageTypes, true);
+
+ // Stream with proper Range support for video/audio seeking
+ $this->streamFileWithRange($realFilePath, basename($realFilePath), $mimeType, $inline);
}
public function zipStatus()