import React from "react";
import _ from "lodash";
import {TagsSelector} from "../Tags";
import {encodeMediaPath, isSupportedArchive, useLocalStorage} from "./Common";
import {getFile, tagFileGroup, untagFileGroup} from "../api";
import {ArchivePreviewContent} from "./ArchivePreview";
import {CbzViewer} from "./CbzViewer";
import {StlViewer} from "react-stl-viewer";
import {Button, ButtonGroup, Header, Icon, IconButton, MediaGate, Menu, Modal, toast} from "./ui";
import {useOneQuery} from "../hooks/customHooks";
import {ShareButton} from "./Share";
import {AddToPlaylistButton} from "./AddToPlaylist";
import {pathDirectory} from "./FileBrowser";
import {InlineErrorBoundary} from "./ErrorBoundary";
import {useLocation} from "react-router";
import {ThemeContext} from "../contexts/contexts";
// Routes where file views should not be tracked
const EXCLUDED_TRACKING_ROUTES = [
/^\/videos\/video\//,
/^\/videos\/channel\/[^/]+\/video\//,
/^\/archive\/\d+/
];
function shouldSkipTracking(pathname) {
return EXCLUDED_TRACKING_ROUTES.some(pattern => pattern.test(pathname));
}
function getMediaPathURL(previewFile) {
if (previewFile['primary_path']) {
return `/media/${encodeMediaPath(previewFile['primary_path'])}`;
} else {
return `/media/${encodeMediaPath(previewFile['path'])}`;
}
}
function getDownloadPathURL(previewFile) {
if (previewFile['primary_path']) {
return `/download/${encodeMediaPath(previewFile['primary_path'])}`;
} else {
return `/download/${encodeMediaPath(previewFile['path'])}`;
}
}
// PrusaSlicer is omitted because it only downloads from domains whitelisted by Prusa
// (printables.com, thingiverse.com, cults3d.com), so it cannot fetch from a WROLPi.
// Bambu Studio asks the user to confirm because a WROLPi is not one of its trusted sites.
export const SLICERS = [
{key: 'orcaslicer', text: 'Orca Slicer', scheme: 'orcaslicer'},
{key: 'bambustudio', text: 'Bambu Studio', scheme: 'bambustudio'},
{key: 'cura', text: 'Cura', scheme: 'cura'},
];
export function getSlicerURL(previewFile, scheme) {
// Slicers register their own URL scheme and download the file themselves, so the
// file parameter must be an absolute URL reachable from the user's computer.
const absoluteURL = new URL(getMediaPathURL(previewFile), window.location.origin).href;
return `${scheme}://open?file=${encodeURIComponent(absoluteURL)}`;
}
function OpenInSlicerButton({previewFile}) {
// Browsers cannot report which slicers are installed, so remember the last one used.
const [slicerKey, setSlicerKey] = useLocalStorage('preferredSlicer', SLICERS[0].key);
const slicer = SLICERS.find(s => s.key === slicerKey) || SLICERS[0];
return
}
function getEpubViewerURL(previewFile) {
let url = previewFile['primary_path'] ?? previewFile['path'];
return `/epub/epub.html?url=/media/${encodeMediaPath(url)}`
}
/*
* The iframe body of a preview.
*
* A component rather than part of `getIframePreviewModal` because it reads the theme: the
* modal builders are plain functions called from an event handler, where hooks cannot run.
*/
export function IframePreview({url, gatePdf}) {
const {isDark, mediaFilterEnabled} = React.useContext(ThemeContext);
/*
* `gatePdf` only for PDFs. Text and the EPUB viewer are documents of our own, which the
* theme's media filter tints; Chrome's PDF viewer is composited out of process and arrives
* in full color however the iframe is styled.
*
* Keyed by `url`, which is load-bearing rather than tidy. The effect that rebuilds a
* preview sets the modal to null and then to the new content in one pass, so React 18
* batches both and reconciles instead of unmounting: the MediaGate fiber survives, and a
* reader who revealed one PDF would get the next one in full color with no prompt -- the
* exact flash this exists to prevent. The key forces a remount per document.
*/
return
}
function getIframePreviewModal(previewFile, url, {gatePdf = false} = {}) {
url = url || getMediaPathURL(previewFile);
return
}
function getImagePreviewModal(previewFile) {
const path = previewFile.primary_path ?? previewFile.path;
const url = getMediaPathURL(previewFile);
// Get the file name from the absolute path.
const name = path.replace(/^.*[\\\/]/, '');
return
{name}
}
function getGenericPreviewModal(previewFile) {
// No special handler for this file type, just open it.
const path = previewFile.primary_path ?? previewFile.path;
const name = path.replace(/^.*[\\\/]/, '');
return {name}
}
export const FilePreviewContext = React.createContext({
previewFile: null,
setPreviewFile: null,
previewModal: null,
setPreviewModal: null,
setCallbacks: null,
});
const MAXIMUM_TEXT_SIZE = 5_000_000;
export function FilePreviewProvider({children}) {
// Data about the file that is being previewed.
const [previewFile, setPreviewFile] = React.useState(null);
// A modal is only displayed once we have data about the file.
const [previewModal, setPreviewModal] = React.useState(null);
const [callbacks, setCallbacks] = React.useState(null);
// The file path from the `preview` URL query.
const [previewQuery, setPreviewQuery] = useOneQuery('preview');
const [errorModalOpen, setErrorModalOpen] = React.useState(false);
const location = useLocation();
// Determine if tracking should be skipped based on current route
const skipTracking = shouldSkipTracking(location.pathname);
const handleErrorModalClose = () => {
setPreviewQuery(null);
setErrorModalOpen(false);
}
const errorModal = Unknown FileCannot display preview, no such file exists: {previewQuery}
React.useEffect(() => {
if (!_.isEmpty(previewFile)) {
// Change URL to match the file that is being previewed.
setPreviewQuery(previewFile['primary_path'] || previewFile['path']);
}
}, [previewFile]);
const initPreviewFile = async () => {
// Get simple information about the file, preview the file.
try {
const file = await getFile(previewQuery, skipTracking);
setPreviewFile(file);
} catch (e) {
console.error(e);
setPreviewModal(errorModal);
setErrorModalOpen(true);
}
}
React.useEffect(() => {
// Navigated to a page which has a file preview active, fetch the information about the file, then display it.
if (previewQuery && !previewFile) {
initPreviewFile();
} else if (!previewQuery && (previewFile || previewModal)) {
// User navigated back/forward and preview query is gone, close the preview.
// Only close if there's actually a preview open (previewFile or previewModal exists).
setPreviewFile(null);
setPreviewModal(null);
}
}, [previewQuery]);
const localFetchFile = async () => {
// Get the file again with its Tags.
const {path, primary_path} = previewFile;
try {
const file = await getFile(primary_path ?? path, skipTracking);
setPreviewFile(file);
} finally {
await handleCallbacks();
}
};
const handleClose = (e) => {
if (e) {
e.preventDefault();
}
setPreviewModal(null);
setPreviewFile(null);
setPreviewQuery(null);
}
const handleCallbacks = async () => {
for (const callbacksKey in callbacks) {
try {
await callbacks[callbacksKey]();
} catch (e) {
console.error(e);
}
}
}
const localAddTag = async (name) => {
await tagFileGroup(previewFile, name);
await localFetchFile();
}
const localRemoveTag = async (name) => {
await untagFileGroup(previewFile, name);
await localFetchFile();
}
function setModalContent(content, url, downloadURL, path, taggable = true, extraButtons = null) {
// All action buttons share `size='small'` and `icon` for consistent height across viewports.
// Close is handled by the Modal's close button (X in the top-right corner) — no dedicated button.
const openButton = url
?
: null;
const tagsDisplay = taggable ?
: null;
const downloadButton = downloadURL
?
: null;
const directoryURL = path ? `/files?folders=${encodeURIComponent(pathDirectory(path))}` : null;
const directoryButton = path
?
: null;
const pathContent = path ?