release(v1.9.11): fix(media): HTTP Range streaming; feat(ui): paged folder strip (closes #68)

This commit is contained in:
Ryan
2025-11-18 15:07:27 -05:00
committed by GitHub
parent 3b1ebdd77f
commit 0ec8103fbf
6 changed files with 551 additions and 222 deletions

View File

@@ -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;}
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;
}

View File

@@ -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 => `
<div class="folder-item"
data-folder="${sf.full}"
draggable="true">
<span class="folder-svg"></span>
<div class="folder-name">
${escapeHTML(sf.name)}
</div>
</div>
`).join("");
if (endIdx < total) {
html += `
<button type="button"
class="folder-strip-load-more">
${t('load_more_folders') || t('load_more') || 'Load more folders'}
</button>
`;
}
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 `
<div class="folder-item"
data-folder="${sf.full}"
draggable="true"
style="display:flex;align-items:center;gap:10px;min-width:0;">
<span class="folder-svg" style="flex:0 0 auto;line-height:0;"></span>
<div class="folder-name"
style="white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">
${escapeHTML(sf.name)}
</div>
</div>
`;
}).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
}

View File

@@ -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");

View File

@@ -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.",