From 0ec8103fbf927938482b616da0b60c0a718cb405 Mon Sep 17 00:00:00 2001 From: Ryan Date: Tue, 18 Nov 2025 15:07:27 -0500 Subject: [PATCH] release(v1.9.11): fix(media): HTTP Range streaming; feat(ui): paged folder strip (closes #68) --- CHANGELOG.md | 25 ++- public/css/styles.css | 92 +++++++++- public/js/fileListView.js | 253 ++++++++++++++++++---------- public/js/filePreview.js | 260 ++++++++++++++++------------- public/js/i18n.js | 3 +- src/controllers/FileController.php | 140 ++++++++++++++-- 6 files changed, 551 insertions(+), 222 deletions(-) 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()