// filePreview.js import { escapeHTML, showToast } from './domUtils.js?v={{APP_QVER}}'; import { t } from './i18n.js?v={{APP_QVER}}'; import { fileData, setFileProgressBadge, setFileWatchedBadge } from './fileListView.js?v={{APP_QVER}}'; // Build a preview URL that always goes through the API layer (respects ACLs/UPLOAD_DIR) export function buildPreviewUrl(folder, name) { const f = (!folder || folder === '') ? 'root' : String(folder); return `/api/file/download.php?folder=${encodeURIComponent(f)}&file=${encodeURIComponent(name)}&inline=1&t=${Date.now()}`; } /* -------------------------------- Share modal (existing) -------------------------------- */ export function openShareModal(file, folder) { const existing = document.getElementById("shareModal"); if (existing) existing.remove(); const modal = document.createElement("div"); modal.id = "shareModal"; modal.classList.add("modal"); modal.innerHTML = ` `; document.body.appendChild(modal); modal.style.display = "block"; document.getElementById("closeShareModal").addEventListener("click", () => modal.remove()); document.getElementById("shareExpiration").addEventListener("change", e => { const container = document.getElementById("customExpirationContainer"); container.style.display = e.target.value === "custom" ? "block" : "none"; }); document.getElementById("generateShareLinkBtn").addEventListener("click", () => { const sel = document.getElementById("shareExpiration"); let value, unit; if (sel.value === "custom") { value = parseInt(document.getElementById("customExpirationValue").value, 10); unit = document.getElementById("customExpirationUnit").value; } else { value = parseInt(sel.value, 10); unit = "minutes"; } const password = document.getElementById("sharePassword").value; fetch("/api/file/createShareLink.php", { method: "POST", credentials: "include", headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken }, body: JSON.stringify({ folder, file: file.name, expirationValue: value, expirationUnit: unit, password }) }) .then(res => res.json()) .then(data => { if (data.token) { const url = `${window.location.origin}/api/file/share.php?token=${encodeURIComponent(data.token)}`; document.getElementById("shareLinkInput").value = url; document.getElementById("shareLinkDisplay").style.display = "block"; } else { showToast(t("error_generating_share") + ": " + (data.error || "Unknown")); } }) .catch(err => { console.error(err); showToast(t("error_generating_share")); }); }); document.getElementById("copyShareLinkBtn").addEventListener("click", () => { const input = document.getElementById("shareLinkInput"); input.select(); document.execCommand("copy"); showToast(t("link_copied")); }); } /* -------------------------------- Media modal viewer -------------------------------- */ const IMG_RE = /\.(jpg|jpeg|png|gif|bmp|webp|svg|ico)$/i; const VID_RE = /\.(mp4|mkv|webm|mov|ogv)$/i; const AUD_RE = /\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i; function ensureMediaModal() { let overlay = document.getElementById("filePreviewModal"); if (overlay) return overlay; overlay = document.createElement("div"); overlay.id = "filePreviewModal"; Object.assign(overlay.style, { position: "fixed", inset: "0", width: "100vw", height: "100vh", backgroundColor: "rgba(0,0,0,0.7)", display: "flex", justifyContent: "center", alignItems: "center", zIndex: "1000" }); const root = document.documentElement; const styles = getComputedStyle(root); const isDark = root.classList.contains('dark-mode'); const panelBg = styles.getPropertyValue('--panel-bg').trim() || styles.getPropertyValue('--bg-color').trim() || (isDark ? '#121212' : '#ffffff'); const textCol = styles.getPropertyValue('--text-color').trim() || (isDark ? '#eaeaea' : '#111111'); const navBg = isDark ? 'rgba(255,255,255,.28)' : 'rgba(0,0,0,.45)'; const navFg = '#fff'; const navBorder = isDark ? 'rgba(255,255,255,.35)' : 'rgba(0,0,0,.25)'; overlay.innerHTML = ` `; document.body.appendChild(overlay); function closeModal() { try { overlay.querySelectorAll("video,audio").forEach(m => { try{m.pause()}catch(_){}}); } catch {} if (overlay._onKey) window.removeEventListener('keydown', overlay._onKey); overlay.remove(); } overlay.querySelector("#closeFileModal").addEventListener("click", closeModal); overlay.addEventListener("click", (e) => { if (e.target === overlay) closeModal(); }); return overlay; } function setTitle(overlay, name) { const el = overlay.querySelector('.media-title-badge'); if (el) el.textContent = name || ''; } function makeMI(name, title) { const b = document.createElement('button'); b.className = `material-icons ${name}`; b.textContent = name; // Material Icons font b.title = title; Object.assign(b.style, { width: "32px", height: "32px", display: "flex", alignItems: "center", justifyContent: "center", background: "rgba(0,0,0,.25)", border: "1px solid rgba(255,255,255,.25)", cursor: "pointer", userSelect: "none", fontSize: "20px", padding: "0", borderRadius: "8px", color: "#fff", lineHeight: "1" }); return b; } function setNavVisibility(overlay, showPrev, showNext) { const prev = overlay.querySelector('.nav-left'); const next = overlay.querySelector('.nav-right'); prev.style.display = showPrev ? 'inline-flex' : 'none'; next.style.display = showNext ? 'inline-flex' : 'none'; } function setRowWatchedBadge(name, watched) { try { const cell = document.querySelector(`tr[data-file-name="${CSS.escape(name)}"] .name-cell`); if (!cell) return; const old = cell.querySelector('.status-badge.watched'); if (watched) { if (!old) { const b = document.createElement('span'); b.className = 'status-badge watched'; b.textContent = t("watched") || t("viewed") || "Watched"; b.style.marginLeft = "6px"; cell.appendChild(b); } } else if (old) { old.remove(); } } catch {} } /* -------------------------------- Entry -------------------------------- */ export function previewFile(fileUrl, fileName) { const overlay = ensureMediaModal(); const container = overlay.querySelector(".file-preview-container"); const actionWrap = overlay.querySelector(".media-actions-bar .action-group"); const statusChip = overlay.querySelector(".media-actions-bar .status-chip"); // replace nav buttons to clear old listeners let prevBtn = overlay.querySelector('.nav-left'); let nextBtn = overlay.querySelector('.nav-right'); const newPrev = prevBtn.cloneNode(true); const newNext = nextBtn.cloneNode(true); prevBtn.replaceWith(newPrev); nextBtn.replaceWith(newNext); prevBtn = newPrev; nextBtn = newNext; // reset container.innerHTML = ""; actionWrap.innerHTML = ""; if (statusChip) statusChip.style.display = 'none'; if (overlay._onKey) window.removeEventListener('keydown', overlay._onKey); overlay._onKey = null; const folder = window.currentFolder || 'root'; const name = fileName; const lower = (name || '').toLowerCase(); const isImage = IMG_RE.test(lower); const isVideo = VID_RE.test(lower); const isAudio = AUD_RE.test(lower); setTitle(overlay, name); /* -------------------- IMAGES -------------------- */ if (isImage) { const img = document.createElement("img"); img.src = fileUrl; img.className = "image-modal-img"; img.style.maxWidth = "88vw"; img.style.maxHeight = "88vh"; img.style.transition = "transform 0.3s ease"; img.dataset.scale = 1; img.dataset.rotate = 0; container.appendChild(img); const zoomInBtn = makeMI('zoom_in', t('zoom_in') || 'Zoom In'); const zoomOutBtn = makeMI('zoom_out', t('zoom_out') || 'Zoom Out'); const rotateLeft = makeMI('rotate_left', t('rotate_left') || 'Rotate Left'); const rotateRight = makeMI('rotate_right', t('rotate_right') || 'Rotate Right'); actionWrap.appendChild(zoomInBtn); actionWrap.appendChild(zoomOutBtn); actionWrap.appendChild(rotateLeft); actionWrap.appendChild(rotateRight); zoomInBtn.addEventListener('click', (e) => { e.stopPropagation(); let s = parseFloat(img.dataset.scale) || 1; s += 0.1; img.dataset.scale = s; img.style.transform = `scale(${s}) rotate(${img.dataset.rotate}deg)`; }); zoomOutBtn.addEventListener('click', (e) => { e.stopPropagation(); let s = parseFloat(img.dataset.scale) || 1; s = Math.max(0.1, s - 0.1); img.dataset.scale = s; img.style.transform = `scale(${s}) rotate(${img.dataset.rotate}deg)`; }); rotateLeft.addEventListener('click', (e) => { e.stopPropagation(); let r = parseFloat(img.dataset.rotate) || 0; r = (r - 90 + 360) % 360; img.dataset.rotate = r; img.style.transform = `scale(${img.dataset.scale}) rotate(${r}deg)`; }); rotateRight.addEventListener('click', (e) => { e.stopPropagation(); let r = parseFloat(img.dataset.rotate) || 0; r = (r + 90) % 360; img.dataset.rotate = r; img.style.transform = `scale(${img.dataset.scale}) rotate(${r}deg)`; }); const images = (Array.isArray(fileData) ? fileData : []).filter(f => IMG_RE.test(f.name)); overlay.mediaType = 'image'; overlay.mediaList = images; overlay.mediaIndex = Math.max(0, images.findIndex(f => f.name === name)); setNavVisibility(overlay, images.length > 1, images.length > 1); const navigate = (dir) => { if (!overlay.mediaList || overlay.mediaList.length < 2) return; overlay.mediaIndex = (overlay.mediaIndex + dir + overlay.mediaList.length) % overlay.mediaList.length; const newFile = overlay.mediaList[overlay.mediaIndex].name; setTitle(overlay, newFile); img.dataset.scale = 1; img.dataset.rotate = 0; img.style.transform = 'scale(1) rotate(0deg)'; img.src = buildPreviewUrl(folder, newFile); }; if (images.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; } overlay.style.display = "flex"; return; } /* -------------------- PDF => new tab -------------------- */ if (lower.endsWith('.pdf')) { const separator = fileUrl.includes('?') ? '&' : '?'; const urlWithTs = fileUrl + separator + 't=' + Date.now(); window.open(urlWithTs, "_blank"); overlay.remove(); 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); const markBtn = document.createElement('button'); const clearBtn = document.createElement('button'); markBtn.className = 'btn btn-sm btn-success'; clearBtn.className = 'btn btn-sm btn-secondary'; markBtn.textContent = t("mark_as_viewed") || "Mark as viewed"; clearBtn.textContent = t("clear_progress") || "Clear progress"; actionWrap.appendChild(markBtn); actionWrap.appendChild(clearBtn); 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'; markBtn.style.display = 'none'; clearBtn.style.display = ''; clearBtn.textContent = 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'; statusChip.style.borderColor = 'rgba(250,204,21,.45)'; statusChip.style.background = 'rgba(250,204,21,.15)'; statusChip.style.color = '#facc15'; markBtn.style.display = ''; clearBtn.style.display = ''; clearBtn.textContent = t('reset_progress') || t('clear_progress') || 'Reset'; return; } // No progress statusChip.style.display = 'none'; markBtn.style.display = ''; clearBtn.style.display = 'none'; } function bindVideoEvents(nm) { const nv = video.cloneNode(true); video.replaceWith(nv); video = nv; video.addEventListener("loadedmetadata", async () => { 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 duration = Math.floor(video.duration || 0); setFileProgressBadge(nm, seconds, duration); showToast((t("resumed_from") || "Resumed from") + " " + Math.floor(state.seconds) + "s"); } else { const ls = localStorage.getItem(lsKey(nm)); if (ls) video.currentTime = parseFloat(ls); } renderStatus(state || null); } catch { 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 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 duration = Math.floor(video.duration || 0); await sendProgress({ nm, seconds: duration, duration, completed: true }); try { localStorage.removeItem(lsKey(nm)); } catch {} showToast(t("marked_viewed") || "Marked as viewed"); setFileWatchedBadge(nm, true); renderStatus({ seconds: duration, duration, completed: true }); }); markBtn.onclick = async () => { 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 }); }; clearBtn.onclick = async () => { 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); }; window.addEventListener("keydown", onKey); overlay._onKey = onKey; } setVideoSrc(name); renderStatus(null); bindVideoEvents(name); overlay.style.display = "flex"; return; } /* -------------------- AUDIO / OTHER -------------------- */ if (isAudio) { const audio = document.createElement("audio"); audio.src = fileUrl; audio.controls = true; audio.className = "audio-modal"; audio.style.maxWidth = "88vw"; container.appendChild(audio); overlay.style.display = "flex"; } else { container.textContent = t("preview_not_available") || "Preview not available for this file type."; overlay.style.display = "flex"; } } /* -------------------------------- Small display helper -------------------------------- */ export function displayFilePreview(file, container) { const actualFile = file.file || file; if (!(actualFile instanceof File)) { console.error("displayFilePreview called with an invalid file object"); return; } container.style.display = "inline-block"; while (container.firstChild) container.removeChild(container.firstChild); if (IMG_RE.test(actualFile.name)) { const img = document.createElement("img"); img.src = URL.createObjectURL(actualFile); img.classList.add("file-preview-img"); container.appendChild(img); } else { const iconSpan = document.createElement("span"); iconSpan.classList.add("material-icons", "file-icon"); iconSpan.textContent = "insert_drive_file"; container.appendChild(iconSpan); } } // expose for HTML onclick usage window.previewFile = previewFile; window.openShareModal = openShareModal;