[ENG-528] QuickPreview isn't correctly handling errors for video/audio playback (#815)

* Centralize the file preview logic in `Thumb.tsx`

* Fix useEffect

* Fix Inspector thumb keeping internal state from previous selected files
 - Change video border to follow video aspect-ratio, just like Finder

* Restore memo to Thumb
 - Only add borders to video thumb, not the video itself

* Simplify and improve Thumb logic
 - Add A internal Thumbnail component
 - Rename main component to FileThumb to match mobile naming
 - Move getIcon utility function to assets/icons

* Add new `useCallbackToWatchResize` hook
 - Replace `ResizeObserver` with new resize hook in `Thumb`
 - Simplify and improve `useIsDark` hook by replacing `react-responsive` with direct usage of WebAPI `matchMedia`
 - Fix `Thumb` src not updating correctly
 - Fix video extension incorrectly showing when size <= 80 in `Thumb`

* Fix `Inspector` not updating thumb type
 - Remove superfluous `newThumb` from `getExplorerItemData`

* Remove superfluous `ThumSize`

* Forgot a `?`

* Fix incorrect className check in `Thumb`
 - Updated changed files to use the hooks root import

* Format

* Fix PDF preview compleatly breaking the application
 - Allow Linux to access both the spacedrive:// custom protocol and the workaround webserver
 - On Linux only use the webserver for audio and video, which don't work when requested through a custom protocol
 - Configure tauri IPC to allow API access to the spacedrive://localhost domain, to avoid PDF previews from breaking the security scope and rendering the application unusable
 - Configure CSP to allow the pdf plugin custom protocol used by webkit
 - Fix race condition between Thumb error handler and thumbType useEffect, by using replacing it with a useLayoutEffect
 - Improve Thumb's error handling
This commit is contained in:
Vítor Vasconcellos 2023-05-18 03:31:15 +00:00 committed by GitHub
parent fb2a4b0137
commit 30e7c9d709
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 503 additions and 385 deletions

View file

@ -7,9 +7,8 @@ use axum::{
middleware::{self, Next},
response::Response,
routing::get,
RequestPartsExt,
RequestPartsExt, Router,
};
use httpz::{Endpoint, HttpEndpoint};
use rand::{distributions::Alphanumeric, Rng};
use serde::Deserialize;
use tauri::{async_runtime::Receiver, plugin::TauriPlugin, Builder, Runtime};
@ -18,7 +17,7 @@ use tracing::debug;
pub(super) async fn setup<R: Runtime>(
app: Builder<R>,
mut rx: Receiver<()>,
endpoint: Endpoint<impl HttpEndpoint>,
router: Router<()>,
) -> Builder<R> {
let auth_token: String = rand::thread_rng()
.sample_iter(&Alphanumeric)
@ -28,7 +27,7 @@ pub(super) async fn setup<R: Runtime>(
let axum_app = axum::Router::new()
.route("/", get(|| async { "Spacedrive Server!" }))
.nest("/spacedrive", endpoint.axum())
.nest("/spacedrive", router)
.route_layer(middleware::from_fn_with_state(
auth_token.clone(),
auth_middleware,

View file

@ -7,7 +7,10 @@ use std::{path::PathBuf, time::Duration};
use sd_core::{custom_uri::create_custom_uri_endpoint, Node, NodeError};
use tauri::{api::path, async_runtime::block_on, plugin::TauriPlugin, Manager, RunEvent, Runtime};
use tauri::{
api::path, async_runtime::block_on, ipc::RemoteDomainAccessScope, plugin::TauriPlugin, Manager,
RunEvent, Runtime,
};
use tokio::{task::block_in_place, time::sleep};
use tracing::{debug, error};
@ -58,18 +61,13 @@ async fn main() -> tauri::Result<()> {
let (node, app) = match result {
Ok((node, router)) => {
// This is a super cringe workaround for: https://github.com/tauri-apps/tauri/issues/3725 & https://bugs.webkit.org/show_bug.cgi?id=146351#c5
let endpoint = create_custom_uri_endpoint(node.clone());
#[cfg(target_os = "linux")]
let app = app_linux::setup(app, rx, endpoint).await;
#[cfg(not(target_os = "linux"))]
let app = app.register_uri_scheme_protocol(
"spacedrive",
endpoint.tauri_uri_scheme("spacedrive"),
);
let app = app_linux::setup(app, rx, create_custom_uri_endpoint(node.clone()).axum()).await;
let app = app
.register_uri_scheme_protocol(
"spacedrive",
create_custom_uri_endpoint(node.clone()).tauri_uri_scheme("spacedrive"),
)
.plugin(rspc::integrations::tauri::plugin(router, {
let node = node.clone();
move || node.clone()
@ -118,6 +116,14 @@ async fn main() -> tauri::Result<()> {
}
});
// Configure IPC for custom protocol
app.ipc_scope().configure_remote_access(
RemoteDomainAccessScope::new("localhost")
.allow_on_scheme("spacedrive")
.add_window("main")
.enable_tauri_api(),
);
Ok(())
})
.on_menu_event(menu::handle_menu_event)

View file

@ -89,7 +89,7 @@
}
],
"security": {
"csp": "default-src spacedrive: asset: https://asset.localhost blob: data: filesystem: ws: wss: http: https: tauri: 'unsafe-eval' 'unsafe-inline' 'self' img-src: 'self'"
"csp": "default-src spacedrive: webkit-pdfjs-viewer: asset: https://asset.localhost blob: data: filesystem: ws: wss: http: https: tauri: 'unsafe-eval' 'unsafe-inline' 'self' img-src: 'self'"
}
}
}

View file

@ -50,22 +50,20 @@ if (customUriServerUrl && !customUriServerUrl?.endsWith('/')) {
customUriServerUrl += '/';
}
function getCustomUriURL(path: string): string {
if (customUriServerUrl) {
const queryParams = customUriAuthToken
? `?token=${encodeURIComponent(customUriAuthToken)}`
: '';
return `${customUriServerUrl}spacedrive/${path}${queryParams}`;
} else {
return convertFileSrc(path, 'spacedrive');
}
}
const platform: Platform = {
platform: 'tauri',
getThumbnailUrlById: (casId) => getCustomUriURL(`thumbnail/${casId}`),
getFileUrl: (libraryId, locationLocalId, filePathId) =>
getCustomUriURL(`file/${libraryId}/${locationLocalId}/${filePathId}`),
getThumbnailUrlById: (casId) => convertFileSrc(`thumbnail/${casId}`, 'spacedrive'),
getFileUrl: (libraryId, locationLocalId, filePathId, _linux_workaround) => {
const path = `file/${libraryId}/${locationLocalId}/${filePathId}`;
if (_linux_workaround && customUriServerUrl) {
const queryParams = customUriAuthToken
? `?token=${encodeURIComponent(customUriAuthToken)}`
: '';
return `${customUriServerUrl}spacedrive/${path}${queryParams}`;
} else {
return convertFileSrc(path, 'spacedrive');
}
},
openLink: shell.open,
getOs,
openDirectoryPickerDialog: () => dialog.open({ directory: true }),

View file

@ -5,6 +5,9 @@ module.exports = {
project: './tsconfig.json'
},
rules: {
'react-hooks/exhaustive-deps': ['warn', { additionalHooks: 'useCallbackToWatchForm' }]
'react-hooks/exhaustive-deps': [
'warn',
{ additionalHooks: '(useCallbackToWatchForm|useCallbackToWatchResize)' }
]
}
};

View file

@ -1,36 +1,116 @@
import * as icons from '@sd/assets/icons';
import { getIcon } from '@sd/assets/icons/util';
import clsx from 'clsx';
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { ExplorerItem, isKeyOf, useLibraryContext } from '@sd/client';
import { useExplorerStore } from '~/hooks/useExplorerStore';
import { useIsDark, usePlatform } from '~/util/Platform';
import {
ImgHTMLAttributes,
memo,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState
} from 'react';
import { ExplorerItem, useLibraryContext } from '@sd/client';
import { useCallbackToWatchResize, useExplorerStore, useIsDark } from '~/hooks';
import { usePlatform } from '~/util/Platform';
import { pdfViewerEnabled } from '~/util/pdfViewer';
import { getExplorerItemData } from '../util';
import classes from './Thumb.module.scss';
export const getIcon = (
isDir: boolean,
isDark: boolean,
kind: string,
extension?: string | null
) => {
if (isDir) return icons[isDark ? 'Folder' : 'Folder_Light'];
interface ThumbnailProps {
src: string;
cover?: boolean;
onLoad?: () => void;
onError?: () => void;
decoding?: ImgHTMLAttributes<HTMLImageElement>['decoding'];
className?: string;
crossOrigin?: ImgHTMLAttributes<HTMLImageElement>['crossOrigin'];
videoBarsSize?: number;
videoExtension?: string;
}
let document: Extract<keyof typeof icons, 'Document' | 'Document_Light'> = 'Document';
if (extension) extension = `${kind}_${extension.toLowerCase()}`;
if (!isDark) {
kind = kind + '_Light';
document = 'Document_Light';
if (extension) extension = extension + '_Light';
}
const Thumbnail = ({
src,
cover,
onLoad,
onError,
decoding,
className,
crossOrigin,
videoBarsSize,
videoExtension
}: ThumbnailProps) => {
const ref = useRef<HTMLImageElement>(null);
const [size, setSize] = useState<null | { width: number; height: number }>(null);
return icons[
extension && isKeyOf(icons, extension) ? extension : isKeyOf(icons, kind) ? kind : document
];
useCallbackToWatchResize(
(rect) => {
const { width, height } = rect;
setSize((width && height && { width, height }) || null);
},
[],
ref
);
return (
<>
<img
// Order matter for crossOrigin attr
// https://github.com/facebook/react/issues/14035#issuecomment-642227899
{...(crossOrigin ? { crossOrigin } : {})}
src={src}
ref={ref}
style={
videoBarsSize
? size && size.height >= size.width
? {
borderLeftWidth: videoBarsSize,
borderRightWidth: videoBarsSize
}
: {
borderTopWidth: videoBarsSize,
borderBottomWidth: videoBarsSize
}
: {}
}
onLoad={onLoad}
onError={() => {
onError?.();
setSize(null);
}}
decoding={decoding}
className={className}
/>
{videoExtension && (
<div
style={
cover
? {}
: size
? {
marginTop: Math.floor(size.height / 2) - 2,
marginLeft: Math.floor(size.width / 2) - 2
}
: { display: 'none' }
}
className={clsx(
cover
? 'bottom-1 right-1'
: 'left-1/2 top-1/2 -translate-x-full -translate-y-full',
'absolute rounded',
'bg-black/60 px-1 py-0.5 text-[9px] font-semibold uppercase opacity-70'
)}
>
{videoExtension}
</div>
)}
</>
);
};
interface VideoThumbSize {
width: number;
height: number;
enum ThumbType {
Icon,
Original,
Thumbnail
}
export interface ThumbProps {
@ -39,156 +119,202 @@ export interface ThumbProps {
cover?: boolean;
className?: string;
loadOriginal?: boolean;
mediaControls?: boolean;
}
function Thumb({ size, cover, ...props }: ThumbProps) {
function FileThumb({ size, cover, ...props }: ThumbProps) {
const isDark = useIsDark();
const platform = usePlatform();
const thumbImg = useRef<HTMLImageElement>(null);
const [thumbSize, setThumbSize] = useState<null | VideoThumbSize>(null);
const { library } = useLibraryContext();
const [thumbLoaded, setThumbLoaded] = useState<boolean>(false);
const [src, setSrc] = useState<string>('#');
const [thumbType, setThumbType] = useState(ThumbType.Icon);
const { locationId, newThumbnails } = useExplorerStore();
const { cas_id, isDir, kind, hasThumbnail, newThumb, extension } = getExplorerItemData(
props.data,
newThumbnails
const itemData = useMemo(
() => getExplorerItemData(props.data, newThumbnails),
[props.data, newThumbnails]
);
// Allows disabling thumbnails when they fail to load
const [useThumb, setUseThumb] = useState<boolean>(hasThumbnail);
// When new thumbnails are generated, reset the useThumb state
// If it fails to load, it will be set back to false by the error handler in the img
useEffect(() => {
if (newThumb) setUseThumb(true);
}, [newThumb]);
// useLayoutEffect is required to ensure the thumbType is always updated before the onError listener can execute,
// thus avoiding improper thumb types changes
useLayoutEffect(() => {
const img = thumbImg.current;
if (cover || kind !== 'Video' || !img || !thumbLoaded) return;
// Reset src when item changes, to allow detection of yet not updated src
setSrc('#');
const resizeObserver = new ResizeObserver(() => {
const { width, height } = img;
setThumbSize(width && height ? { width, height } : null);
if (props.loadOriginal) {
setThumbType(ThumbType.Original);
} else if (itemData.hasThumbnail) {
setThumbType(ThumbType.Thumbnail);
} else {
setThumbType(ThumbType.Icon);
}
}, [props.loadOriginal, itemData]);
useEffect(() => {
const { cas_id, kind, isDir, extension } = itemData;
switch (thumbType) {
case ThumbType.Original:
if (locationId) {
setSrc(
platform.getFileUrl(
library.uuid,
locationId,
props.data.item.id,
// Workaround Linux webview not supporting playng video and audio through custom protocol urls
kind == 'Video' || kind == 'Audio'
)
);
} else {
setThumbType(ThumbType.Thumbnail);
}
break;
case ThumbType.Thumbnail:
if (cas_id) {
setSrc(platform.getThumbnailUrlById(cas_id));
} else {
setThumbType(ThumbType.Icon);
}
break;
default:
setSrc(getIcon(kind, isDir, isDark, extension));
break;
}
}, [props.data.item.id, isDark, library.uuid, itemData, platform, thumbType, locationId]);
const onError = () => {
if (src === '#') return;
setThumbType((prevThumbType) => {
return prevThumbType === ThumbType.Original && itemData.hasThumbnail
? ThumbType.Thumbnail
: ThumbType.Icon;
});
};
resizeObserver.observe(img);
return () => resizeObserver.disconnect();
}, [kind, cover, thumbImg, thumbLoaded]);
// Only Videos and Images can show the original file
const loadOriginal = (kind === 'Video' || kind === 'Image') && props.loadOriginal;
const src = useThumb
? loadOriginal && locationId
? platform.getFileUrl(library.uuid, locationId, props.data.item.id)
: cas_id && platform.getThumbnailUrlById(cas_id)
: null;
let style = {};
if (size && kind === 'Video') {
const videoBarsHeight = Math.floor(size / 10);
style = {
borderTopWidth: videoBarsHeight,
borderBottomWidth: videoBarsHeight
};
}
const { kind, extension } = itemData;
const childClassName = 'max-h-full max-w-full object-contain';
return (
<div
style={size ? { maxWidth: size, width: size - 10, height: size } : {}}
className={clsx(
'relative flex shrink-0 items-center justify-center',
src && kind !== 'Video' && [size && 'border-2 border-transparent'],
size &&
kind !== 'Video' &&
thumbType !== ThumbType.Icon &&
'border-2 border-transparent',
size || ['h-full', cover ? 'w-full overflow-hidden' : 'w-[90%]'],
props.className
)}
>
{src ? (
kind === 'Video' && loadOriginal ? (
<video
src={src}
onCanPlay={(e) => {
const video = e.target as HTMLVideoElement;
// Why not use the element's attribute? Because React...
// https://github.com/facebook/react/issues/10389
video.loop = true;
video.muted = true;
}}
style={style}
autoPlay
className={clsx(
childClassName,
size && 'rounded border-x-0 border-black',
props.className
)}
playsInline
/>
) : (
<>
<img
src={src}
ref={thumbImg}
style={style}
onLoad={() => {
setUseThumb(true);
setThumbLoaded(true);
}}
onError={() => {
setUseThumb(false);
setThumbSize(null);
setThumbLoaded(false);
}}
decoding="async"
className={clsx(
cover
? 'min-h-full min-w-full object-cover object-center'
: childClassName,
'shadow shadow-black/30',
kind === 'Video' ? 'rounded' : 'rounded-sm',
classes.checkers,
size &&
(kind === 'Video'
? 'border-x-0 border-black'
: size > 60 && 'border-2 border-app-line'),
props.className
)}
/>
{kind === 'Video' && (!size || size > 80) && (
<div
style={
cover
? {}
: thumbSize
? {
marginTop: Math.floor(thumbSize.height / 2) - 2,
marginLeft: Math.floor(thumbSize.width / 2) - 2
}
: { display: 'none' }
}
{(() => {
switch (thumbType) {
case ThumbType.Original:
switch (extension === 'pdf' && pdfViewerEnabled() ? 'PDF' : kind) {
case 'PDF':
return (
<object
data={src}
type="application/pdf"
className={clsx(
'h-full w-full border-0',
childClassName,
props.className
)}
/>
);
case 'Video':
return (
<video
crossOrigin="anonymous"
src={src}
onError={onError}
autoPlay
controls={props.mediaControls}
onCanPlay={(e) => {
const video = e.target as HTMLVideoElement;
// Why not use the element's attribute? Because React...
// https://github.com/facebook/react/issues/10389
video.loop = !props.mediaControls;
video.muted = !props.mediaControls;
}}
className={clsx(
childClassName,
size && 'rounded border-x-0 border-black',
props.className
)}
playsInline
>
<p>Video preview is not supported.</p>
</video>
);
case 'Audio':
return (
<>
<img
src={getIcon('Audio', false, isDark, extension)}
decoding={size ? 'async' : 'sync'}
className={clsx(childClassName, props.className)}
/>
{props.mediaControls && (
<audio
crossOrigin="anonymous"
src={src}
onError={onError}
controls
autoPlay
className="absolute left-2/4 top-full w-full -translate-x-1/2 translate-y-[-150%]"
>
<p>Audio preview is not supported.</p>
</audio>
)}
</>
);
}
// eslint-disable-next-line no-fallthrough
case ThumbType.Thumbnail:
return (
<Thumbnail
src={src}
cover={cover}
onError={onError}
decoding={size ? 'async' : 'sync'}
className={clsx(
cover
? 'bottom-1 right-1'
: 'left-1/2 top-1/2 -translate-x-full -translate-y-full',
'absolute rounded',
'bg-black/60 px-1 py-0.5 text-[9px] font-semibold uppercase opacity-70'
? 'min-h-full min-w-full object-cover object-center'
: childClassName,
kind === 'Video' ? 'rounded' : 'rounded-sm',
ThumbType.Original || [
classes.checkers,
'shadow shadow-black/30'
],
size &&
(kind === 'Video'
? 'border-x-0 border-black'
: size > 60 && 'border-2 border-app-line'),
props.className
)}
>
{extension}
</div>
)}
</>
)
) : (
<img
src={getIcon(isDir, isDark, kind, extension)}
decoding="async"
className={clsx(childClassName, props.className)}
/>
)}
crossOrigin={ThumbType.Original && 'anonymous'}
videoBarsSize={
(kind === 'Video' && size && Math.floor(size / 10)) || 0
}
videoExtension={
(kind === 'Video' &&
(size == null || size > 80) &&
extension) ||
''
}
/>
);
default:
return (
<img
src={src}
decoding={size ? 'async' : 'sync'}
className={clsx(childClassName, props.className)}
/>
);
}
})()}
</div>
);
}
export default Thumb;
export default memo(FileThumb);

View file

@ -3,9 +3,9 @@ import clsx from 'clsx';
import { memo, useEffect, useMemo, useRef, useState } from 'react';
import { useKey, useOnWindowResize } from 'rooks';
import { ExplorerItem, formatBytes } from '@sd/client';
import { getExplorerStore, useExplorerStore } from '~/hooks/useExplorerStore';
import { getExplorerStore, useExplorerStore } from '~/hooks';
import RenameTextBox from './File/RenameTextBox';
import Thumb from './File/Thumb';
import FileThumb from './File/Thumb';
import { ViewItem } from './View';
import { useExplorerViewContext } from './ViewContext';
import { getItemFilePath } from './util';
@ -40,7 +40,7 @@ const GridViewItem = memo(({ data, selected, index, ...props }: GridViewItemProp
}
)}
>
<Thumb data={data} size={explorerStore.gridItemSize} />
<FileThumb data={data} size={explorerStore.gridItemSize} />
</div>
<div className="flex flex-col justify-center">
{filePathData && (

View file

@ -16,11 +16,14 @@ import { CaretDown, CaretUp } from 'phosphor-react';
import { memo, useEffect, useMemo, useRef, useState } from 'react';
import { useKey, useOnWindowResize } from 'rooks';
import { ExplorerItem, FilePath, ObjectKind, isObject, isPath } from '@sd/client';
import { useDismissibleNoticeStore } from '~/hooks/useDismissibleNoticeStore';
import { getExplorerStore, useExplorerStore } from '~/hooks/useExplorerStore';
import { useScrolled } from '~/hooks/useScrolled';
import {
getExplorerStore,
useDismissibleNoticeStore,
useExplorerStore,
useScrolled
} from '~/hooks';
import RenameTextBox from './File/RenameTextBox';
import Thumb from './File/Thumb';
import FileThumb from './File/Thumb';
import { InfoPill } from './Inspector';
import { ViewItem } from './View';
import { useExplorerViewContext } from './ViewContext';
@ -104,7 +107,7 @@ export default () => {
return (
<div className="relative flex items-center">
<div className="mr-[10px] flex h-6 w-12 shrink-0 items-center justify-center">
<Thumb data={file} size={35} />
<FileThumb data={file} size={35} />
</div>
{filePathData && (
<RenameTextBox

View file

@ -6,9 +6,8 @@ import React from 'react';
import { useKey, useOnWindowResize } from 'rooks';
import { ExplorerItem } from '@sd/client';
import { Button } from '@sd/ui';
import { useDismissibleNoticeStore } from '~/hooks/useDismissibleNoticeStore';
import { getExplorerStore, useExplorerStore } from '~/hooks/useExplorerStore';
import Thumb from './File/Thumb';
import { getExplorerStore, useDismissibleNoticeStore, useExplorerStore } from '~/hooks';
import FileThumb from './File/Thumb';
import { ViewItem } from './View';
import { useExplorerViewContext } from './ViewContext';
@ -36,7 +35,7 @@ const MediaViewItem = memo(({ data, index }: MediaViewItemProps) => {
selected && 'bg-app-selected/20'
)}
>
<Thumb
<FileThumb
size={0}
data={data}
cover={explorerStore.mediaAspectSquare}

View file

@ -1,108 +1,20 @@
import * as Dialog from '@radix-ui/react-dialog';
import { animated, useTransition } from '@react-spring/web';
import clsx from 'clsx';
import { XCircle } from 'phosphor-react';
import { useEffect, useRef, useState } from 'react';
import { subscribeKey } from 'valtio/utils';
import { ExplorerItem } from '~/../packages/client/src';
import { showAlertDialog } from '~/components/AlertDialog';
import { getExplorerStore } from '~/hooks/useExplorerStore';
import { usePlatform } from '~/util/Platform';
import { ExplorerItem } from '@sd/client';
import { getExplorerStore } from '~/hooks';
import FileThumb from './File/Thumb';
import { getExplorerItemData } from './util';
const AnimatedDialogOverlay = animated(Dialog.Overlay);
const AnimatedDialogContent = animated(Dialog.Content);
export interface QuickPreviewProps extends Dialog.DialogProps {
libraryUuid: string;
transformOrigin?: string;
}
interface FilePreviewProps {
src: string;
kind: null | string;
onError: () => void;
explorerItem: ExplorerItem;
}
/**
* Check if webview can display PDFs
* https://developer.mozilla.org/en-US/docs/Web/API/Navigator/pdfViewerEnabled
* https://developer.mozilla.org/en-US/docs/Web/API/Navigator/mimeTypes
* https://developer.mozilla.org/en-US/docs/Web/API/Navigator/plugins
*/
const pdfViewerEnabled = () => {
// pdfViewerEnabled is quite new, Safari only started supporting it in march 2023
// https://caniuse.com/?search=pdfViewerEnabled
if ('pdfViewerEnabled' in navigator && navigator.pdfViewerEnabled) return true;
// This is deprecated, but should be supported on all browsers/webviews
// https://caniuse.com/mdn-api_navigator_mimetypes
if (navigator.mimeTypes) {
if ('application/pdf' in navigator.mimeTypes)
return (navigator.mimeTypes['application/pdf'] as null | MimeType)?.enabledPlugin;
if ('text/pdf' in navigator.mimeTypes)
return (navigator.mimeTypes['text/pdf'] as null | MimeType)?.enabledPlugin;
}
// Last ditch effort
// https://caniuse.com/mdn-api_navigator_plugins
return 'PDF Viewer' in navigator.plugins;
};
function FilePreview({ explorerItem, kind, src, onError }: FilePreviewProps) {
const className = clsx('w-full object-contain');
const fileThumb = <FileThumb size={0} data={explorerItem} cover className={className} />;
switch (kind) {
case 'PDF':
return <object data={src} type="application/pdf" className="h-full w-full border-0" />;
case 'Image':
return (
<img
src={src}
alt="File preview"
onError={onError}
className={className}
crossOrigin="anonymous"
/>
);
case 'Audio':
return (
<>
{fileThumb}
<audio
src={src}
onError={onError}
controls
autoPlay
className="absolute left-2/4 top-full w-full -translate-x-1/2 translate-y-[-150%]"
crossOrigin="anonymous"
>
<p>Audio preview is not supported.</p>
</audio>
</>
);
case 'Video':
return (
<video
src={src}
onError={onError}
controls
autoPlay
className={className}
crossOrigin="anonymous"
playsInline
>
<p>Video preview is not supported.</p>
</video>
);
default:
return fileThumb;
}
}
export function QuickPreview({ libraryUuid, transformOrigin }: QuickPreviewProps) {
const platform = usePlatform();
export function QuickPreview({ transformOrigin }: QuickPreviewProps) {
const explorerItem = useRef<null | ExplorerItem>(null);
const explorerStore = getExplorerStore();
const [isOpen, setIsOpen] = useState<boolean>(false);
@ -127,15 +39,6 @@ export function QuickPreview({ libraryUuid, transformOrigin }: QuickPreviewProps
[explorerStore]
);
const onPreviewError = () => {
setIsOpen(false);
explorerStore.quickViewObject = null;
showAlertDialog({
title: 'Error',
value: 'Could not load file preview.'
});
};
const transitions = useTransition(isOpen, {
from: {
opacity: 0,
@ -160,22 +63,6 @@ export function QuickPreview({ libraryUuid, transformOrigin }: QuickPreviewProps
if (!show || explorerItem.current == null) return null;
const { item } = explorerItem.current;
const locationId =
'location_id' in item ? item.location_id : explorerStore.locationId;
if (locationId == null) {
onPreviewError();
return null;
}
const { kind, extension } = getExplorerItemData(explorerItem.current);
const preview = (
<FilePreview
src={platform.getFileUrl(libraryUuid, locationId, item.id)}
kind={extension === 'pdf' && pdfViewerEnabled() ? 'PDF' : kind}
onError={onPreviewError}
explorerItem={explorerItem.current}
/>
);
return (
<>
@ -207,7 +94,15 @@ export function QuickPreview({ libraryUuid, transformOrigin }: QuickPreviewProps
</span>
</Dialog.Title>
</nav>
<div className="flex shrink h-full overflow-hidden">{preview}</div>
<div className="flex h-full w-full shrink items-center justify-center overflow-hidden">
<FileThumb
size={0}
data={explorerItem.current}
className="w-full"
loadOriginal
mediaControls
/>
</div>
</div>
</AnimatedDialogContent>
</Dialog.Portal>

View file

@ -10,8 +10,7 @@ export function getExplorerItemData(data: ExplorerItem, newThumbnails?: Record<s
cas_id: filePath?.cas_id || null,
isDir: isPath(data) && data.item.is_dir,
kind: (ObjectKind[objectData?.kind ?? 0] as ObjectKindKey) || null,
newThumb: !!newThumbnails?.[filePath?.cas_id || ''],
hasThumbnail: data.has_thumbnail || !!newThumbnails?.[filePath?.cas_id || ''] || false,
hasThumbnail: data.has_thumbnail || newThumbnails?.[filePath?.cas_id || ''] || false,
extension: filePath?.extension || null
};
}

View file

@ -54,7 +54,7 @@ const Layout = () => {
<Suspense fallback={<div className="h-screen w-screen bg-app" />}>
<Outlet />
</Suspense>
<QuickPreview libraryUuid={library.uuid} />
<QuickPreview />
</LibraryContextProvider>
) : (
<h1 className="p-4 text-white">

View file

@ -1,4 +1,5 @@
export * from './useCallbackToWatchForm';
export * from './useCallbackToWatchResize';
export * from './useClickOutside';
export * from './useCounter';
export * from './useDebouncedForm';
@ -7,10 +8,12 @@ export * from './useExplorerStore';
export * from './useExplorerTopBarOptions';
export * from './useFocusState';
export * from './useInputState';
export * from './useIsDark';
export * from './useKeyboardHandler';
export * from './useOperatingSystem';
export * from './useScrolled';
export * from './useSearchStore';
export * from './useSpacedropState';
export * from './useToasts';
export * from './useZodRouteParams';
export * from './useZodSearchParams';

View file

@ -3,25 +3,21 @@ import { EventType, FieldPath, FieldValues, UseFormReturn } from 'react-hook-for
const noop = () => {};
type Cb<S extends FieldValues> = (
value: S,
info: {
name?: FieldPath<S>;
type?: EventType;
}
) => void | Promise<void>;
export function useCallbackToWatchForm<S extends FieldValues>(
callback: (
value: S,
info: {
name?: FieldPath<S>;
type?: EventType;
}
) => void | Promise<void>,
callback: Cb<S>,
deps: [UseFormReturn<S, unknown>, ...React.DependencyList]
): void;
export function useCallbackToWatchForm<S extends FieldValues>(
callback: (
value: S,
info: {
name?: FieldPath<S>;
type?: EventType;
}
) => void | Promise<void>,
callback: Cb<S>,
deps: React.DependencyList,
form: UseFormReturn<S, unknown>
): void;
@ -39,13 +35,7 @@ export function useCallbackToWatchForm<S extends FieldValues>(
* @param form - Form to watch. If not provided, it will be taken from the first element of the dependency list
*/
export function useCallbackToWatchForm<S extends FieldValues>(
callback: (
value: S,
info: {
name?: FieldPath<S>;
type?: EventType;
}
) => void | Promise<void>,
callback: Cb<S>,
deps: React.DependencyList,
form?: UseFormReturn<S, unknown>
): void {

View file

@ -0,0 +1,77 @@
import { DependencyList, Dispatch, RefObject, SetStateAction, useCallback, useEffect } from 'react';
export type ResizeRect = Readonly<Omit<DOMRectReadOnly, 'toJSON'>>;
type Cb = (rect: ResizeRect) => void;
const defaultRect: ResizeRect = {
y: 0,
x: 0,
top: 0,
left: 0,
right: 0,
width: 0,
height: 0,
bottom: 0
};
const observedElementsCb = new WeakMap<Element, Set<Cb>>();
// Why use a single ResizeObserver instead of one per component?
// https://github.com/WICG/resize-observer/issues/59
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const elem = entry.target;
const cbs = observedElementsCb.get(elem);
if (cbs) {
// TODO: contentRect is included in the spec for web compat reasons, and may be deprecated one day
// Find a way to reconstruct contentRect from the other properties
// Do not use elem.getBoundingClientRect() as it is very CPU expensive
for (const cb of cbs) cb(entry.contentRect);
} else {
resizeObserver.unobserve(elem);
}
}
});
export function useCallbackToWatchResize(
callback: Cb,
deps: [RefObject<Element>, ...React.DependencyList]
): void;
export function useCallbackToWatchResize(
callback: Cb,
deps: React.DependencyList,
_ref: RefObject<Element>
): void;
export function useCallbackToWatchResize(
callback: Cb,
deps: DependencyList,
_ref?: RefObject<Element>
) {
const ref = _ref ?? (deps[0] as RefObject<Element> | undefined);
if (ref == null) throw new Error('Element not provided');
// Disable lint warning because this hook is a wrapper for useCallback
// eslint-disable-next-line react-hooks/exhaustive-deps
const onResize = useCallback(callback, deps);
useEffect(() => {
const elem = ref.current;
if (elem == null) {
onResize(defaultRect);
return;
}
const setStates =
observedElementsCb.get(elem) ?? new Set<Dispatch<SetStateAction<ResizeRect>>>();
observedElementsCb.set(elem, setStates);
setStates.add(onResize);
resizeObserver.observe(elem);
return () => {
resizeObserver.unobserve(elem);
setStates.delete(onResize);
if (setStates.size === 0) observedElementsCb.delete(elem);
};
}, [ref, onResize]);
}

View file

@ -0,0 +1,16 @@
import { useEffect, useState } from 'react';
// Use a media query to detect light theme changes, because our default theme is dark
const lightMediaQuery = matchMedia('(prefers-color-scheme: light)');
export function useIsDark(): boolean {
const [isDark, setIsDark] = useState(true);
useEffect(() => {
const handleChange = () => setIsDark(!lightMediaQuery.matches);
lightMediaQuery.addEventListener('change', handleChange);
return () => lightMediaQuery.removeEventListener('change', handleChange);
}, [setIsDark]);
return isDark;
}

View file

@ -52,7 +52,6 @@
"react-json-view": "^1.21.3",
"react-loading-skeleton": "^3.1.0",
"react-qr-code": "^2.0.11",
"react-responsive": "^9.0.2",
"react-router": "6.9.0",
"react-router-dom": "6.9.0",
"remix-params-helper": "^0.4.10",

View file

@ -1,5 +1,4 @@
import { PropsWithChildren, createContext, useContext, useState } from 'react';
import { useMediaQuery } from 'react-responsive';
export type OperatingSystem = 'browser' | 'linux' | 'macOS' | 'windows' | 'unknown';
@ -8,7 +7,12 @@ export type OperatingSystem = 'browser' | 'linux' | 'macOS' | 'windows' | 'unkno
export type Platform = {
platform: 'web' | 'tauri'; // This represents the specific platform implementation
getThumbnailUrlById: (casId: string) => string;
getFileUrl: (libraryId: string, locationLocalId: number, filePathId: number) => string;
getFileUrl: (
libraryId: string,
locationLocalId: number,
filePathId: number,
_linux_workaround?: boolean
) => string;
openLink: (url: string) => void;
demoMode?: boolean; // TODO: Remove this in favour of demo mode being handled at the React Query level
getOs?(): Promise<OperatingSystem>;
@ -45,18 +49,3 @@ export function PlatformProvider({
}: PropsWithChildren<{ platform: Platform }>) {
return <context.Provider value={platform}>{children}</context.Provider>;
}
export const useIsDark = () => {
const systemPrefersDark = useMediaQuery(
{
query: '(prefers-color-scheme: dark)'
},
undefined,
(prefersDark: boolean) => {
setIsDark(prefersDark);
}
);
const [isDark, setIsDark] = useState(systemPrefersDark);
return isDark;
};

View file

@ -0,0 +1,24 @@
/**
* Check if webview can display PDFs
* https://developer.mozilla.org/en-US/docs/Web/API/Navigator/pdfViewerEnabled
* https://developer.mozilla.org/en-US/docs/Web/API/Navigator/mimeTypes
* https://developer.mozilla.org/en-US/docs/Web/API/Navigator/plugins
*/
export const pdfViewerEnabled = () => {
// pdfViewerEnabled is quite new, Safari only started supporting it in march 2023
// https://caniuse.com/?search=pdfViewerEnabled
if ('pdfViewerEnabled' in navigator && navigator.pdfViewerEnabled) return true;
// This is deprecated, but should be supported on all browsers/webviews
// https://caniuse.com/mdn-api_navigator_mimetypes
if (navigator.mimeTypes) {
if ('application/pdf' in navigator.mimeTypes)
return !!(navigator.mimeTypes['application/pdf'] as null | MimeType)?.enabledPlugin;
if ('text/pdf' in navigator.mimeTypes)
return !!(navigator.mimeTypes['text/pdf'] as null | MimeType)?.enabledPlugin;
}
// Last ditch effort
// https://caniuse.com/mdn-api_navigator_plugins
return 'PDF Viewer' in navigator.plugins;
};

View file

@ -0,0 +1,26 @@
import * as icons from '.';
export const getIcon = (
kind: string,
isDir?: boolean,
isDark?: boolean,
extension?: string | null
) => {
if (isDir) return icons[isDark ? 'Folder' : 'Folder_Light'];
let document: Extract<keyof typeof icons, 'Document' | 'Document_Light'> = 'Document';
if (extension) extension = `${kind}_${extension.toLowerCase()}`;
if (!isDark) {
kind = kind + '_Light';
document = 'Document_Light';
if (extension) extension = extension + '_Light';
}
return icons[
(extension && extension in icons
? extension
: kind in icons
? kind
: document) as keyof typeof icons
];
};

View file

@ -743,9 +743,6 @@ importers:
react-qr-code:
specifier: ^2.0.11
version: 2.0.11(react@18.2.0)
react-responsive:
specifier: ^9.0.2
version: 9.0.2(react@18.2.0)
react-router:
specifier: 6.9.0
version: 6.9.0(react@18.2.0)
@ -9551,10 +9548,6 @@ packages:
webpack: 5.82.0(esbuild@0.17.18)
dev: false
/css-mediaquery@0.1.2:
resolution: {integrity: sha512-COtn4EROW5dBGlE/4PiKnh6rZpAPxDeFLaEEwt4i10jpDMFt2EhQGS79QmmrO+iKCHv0PU/HrOWEhijFd1x99Q==}
dev: false
/css-select@4.3.0:
resolution: {integrity: sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==}
dependencies:
@ -12005,10 +11998,6 @@ packages:
engines: {node: '>=14.18.0'}
dev: false
/hyphenate-style-name@1.0.4:
resolution: {integrity: sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ==}
dev: false
/iconv-lite@0.4.24:
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
engines: {node: '>=0.10.0'}
@ -13336,12 +13325,6 @@ packages:
dev: true
optional: true
/matchmediaquery@0.3.1:
resolution: {integrity: sha512-Hlk20WQHRIm9EE9luN1kjRjYXAQToHOIAHPJn9buxBwuhfTHoKUcX+lXBbxc85DVQfXYbEQ4HcwQdd128E3qHQ==}
dependencies:
css-mediaquery: 0.1.2
dev: false
/md5-file@3.2.3:
resolution: {integrity: sha512-3Tkp1piAHaworfcCgH0jKbTvj1jWWFgbvh2cXaNCgHwyTCBxxvD1Y04rmfpvdPm1P4oXMOpm6+2H7sr7v9v8Fw==}
engines: {node: '>=0.10'}
@ -16086,19 +16069,6 @@ packages:
use-sidecar: 1.1.2(@types/react@18.2.5)(react@18.2.0)
dev: false
/react-responsive@9.0.2(react@18.2.0):
resolution: {integrity: sha512-+4CCab7z8G8glgJoRjAwocsgsv6VA2w7JPxFWHRc7kvz8mec1/K5LutNC2MG28Mn8mu6+bu04XZxHv5gyfT7xQ==}
engines: {node: '>=0.10'}
peerDependencies:
react: '>=16.8.0'
dependencies:
hyphenate-style-name: 1.0.4
matchmediaquery: 0.3.1
prop-types: 15.8.1
react: 18.2.0
shallow-equal: 1.2.1
dev: false
/react-router-dom@6.9.0(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-/seUAPY01VAuwkGyVBPCn1OXfVbaWGGu4QN9uj0kCPcTyNYgL1ldZpxZUpRU7BLheKQI4Twtl/OW2nHRF1u26Q==}
engines: {node: '>=14'}
@ -16868,10 +16838,6 @@ packages:
dependencies:
kind-of: 6.0.3
/shallow-equal@1.2.1:
resolution: {integrity: sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA==}
dev: false
/shebang-command@1.2.0:
resolution: {integrity: sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==}
engines: {node: '>=0.10.0'}