Update preview logic

This commit is contained in:
mbaharip
2024-05-08 08:55:40 +07:00
parent 3978928995
commit c9431dcffc
7 changed files with 447 additions and 151 deletions
+59 -16
View File
@@ -1,7 +1,7 @@
"use client";
import dynamic from "next/dynamic";
import { useEffect, useState } from "react";
import AudioPlayer from "react-h5-audio-player";
import "react-h5-audio-player/lib/styles.css";
import { z } from "zod";
import { Schema_File } from "~/schema";
@@ -10,7 +10,27 @@ import { cn } from "~/utils";
import Icon from "~/components/Icon";
import { CreateDownloadToken } from "./actions";
import "./r5-style.css";
// import "./r5-style.css";
const Plyr = dynamic(() => import("plyr-react"), {
ssr: false,
loading: () => (
<div
className={cn(
"h-auto min-h-[50dvh] w-full",
"flex flex-grow flex-col items-center justify-center gap-3",
)}
>
<Icon
name='LoaderCircle'
size={32}
className='animate-spin text-foreground'
/>
<p>Loading player...</p>
</div>
),
});
type Props = {
file: z.infer<typeof Schema_File>;
@@ -28,7 +48,7 @@ export default function PreviewAudio({ file }: Props) {
return;
}
const token = await CreateDownloadToken();
setAudioSrc(`/api/download/${file.encryptedId}?token=${token}`);
setAudioSrc(`/api/stream/${file.encryptedId}?token=${token}`);
} catch (error) {
const e = error as Error;
console.error(e);
@@ -66,20 +86,43 @@ export default function PreviewAudio({ file }: Props) {
</div>
) : (
<div className='w-full'>
<AudioPlayer
autoPlay={false}
layout='stacked-reverse'
src={audioSrc}
showJumpControls={false}
showFilledVolume
style={{
borderRadius: "var(--radius)",
<Plyr
source={{
type: "audio",
sources: [
{
src: audioSrc,
type: file.mimeType,
size: file.size,
},
],
title: file.name,
}}
onError={(e) => {
console.error(e);
setError(
"Could not preview this audio, try downloading the file",
);
options={{
controls: [
"play-large",
"play",
"progress",
"current-time",
"duration",
"mute",
"volume",
"settings",
"fullscreen",
],
volume: 0.5,
muted: false,
loop: {
active: false,
},
speed: {
selected: 1,
options: [0.5, 1, 1.5, 2],
},
keyboard: {
focused: true,
global: false,
},
}}
/>
</div>
+45 -10
View File
@@ -1,6 +1,6 @@
"use client";
import DocViewer, { DocViewerRenderers } from "@cyntler/react-doc-viewer";
import DocViewer, { DocRenderer } from "@cyntler/react-doc-viewer";
import { useEffect, useState } from "react";
import { z } from "zod";
import { Schema_File } from "~/schema";
@@ -8,15 +8,49 @@ import { cn } from "~/utils";
import Icon from "~/components/Icon";
import config from "~/config/gIndex.config";
import { CreateDownloadToken } from "./actions";
const GoogleDocsViewerRenderer: DocRenderer = ({
mainState: { currentDocument },
}) => {
if (!currentDocument || !currentDocument.uri) return null;
const viewerUrl = new URL(`/gview`, "https://docs.google.com");
viewerUrl.searchParams.set("url", currentDocument.uri);
viewerUrl.searchParams.set("embedded", "true");
return (
<iframe
src={viewerUrl.toString()}
className='m-0 h-full max-h-[70dvh] min-h-[70dvh] w-full overflow-hidden rounded-[var(--radius)] border border-border p-0 !text-black'
frameBorder={0}
/>
);
};
GoogleDocsViewerRenderer.fileTypes = [
"application/vnd.google-apps.document",
"application/vnd.google-apps.presentation",
"application/vnd.google-apps.spreadsheet",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.ms-powerpoint",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"text/csv",
"application/pdf",
"application/vnd.oasis.opendocument.text",
];
GoogleDocsViewerRenderer.weight = 100;
type Props = {
file: z.infer<typeof Schema_File>;
};
export default function PreviewDoc({ file }: Props) {
const [docSrc, setDocSrc] = useState<string>("");
const [docBuffer, setDocBuffer] = useState<ArrayBuffer>();
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string>("");
@@ -28,11 +62,12 @@ export default function PreviewDoc({ file }: Props) {
return;
}
const token = await CreateDownloadToken();
const buffer = await fetch(
`/api/download/${file.encryptedId}?token=${token}`,
).then((res) => res.arrayBuffer());
setDocBuffer(buffer);
setDocSrc(`/api/download/${file.encryptedId}?token=${token}`);
const streamURL = new URL(
`/api/stream/${file.encryptedId}`,
config.basePath,
);
streamURL.searchParams.set("token", token);
setDocSrc(streamURL.toString());
} catch (error) {
const e = error as Error;
console.error(e);
@@ -74,12 +109,11 @@ export default function PreviewDoc({ file }: Props) {
documents={[
{
uri: docSrc,
fileData: docBuffer,
fileName: file.name,
fileType: file.mimeType,
},
]}
pluginRenderers={DocViewerRenderers}
pluginRenderers={[GoogleDocsViewerRenderer]}
config={{
header: {
disableHeader: true,
@@ -118,7 +152,8 @@ export default function PreviewDoc({ file }: Props) {
},
}}
className={cn(
"h-full max-h-[70dvh] min-h-[70dvh] w-full rounded-[var(--radius)] border border-border !text-black",
"h-full max-h-[70dvh] min-h-[70dvh] w-full overflow-hidden rounded-[var(--radius)] !text-black",
"rounded-[var(--radius)] [&>div#proxy-renderer]:overflow-hidden ",
)}
theme={{
disableThemeScrollbar: true,
+64 -11
View File
@@ -6,6 +6,9 @@ import { Schema_File } from "~/schema";
import { cn } from "~/utils";
import Icon from "~/components/Icon";
import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert";
import config from "~/config/gIndex.config";
import { CreateDownloadToken } from "./actions";
@@ -13,7 +16,10 @@ type Props = {
file: z.infer<typeof Schema_File>;
};
export default function PreviewImage({ file }: Props) {
const [imgSrc, setImgSrc] = useState<string>("");
const [imgSrc, setImgSrc] = useState<string>(
`/api/thumb/${file.encryptedId}?size=4`,
);
const [imgLoaded, setImgLoaded] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string>("");
@@ -25,7 +31,31 @@ export default function PreviewImage({ file }: Props) {
return;
}
const token = await CreateDownloadToken();
setImgSrc(`/api/download/${file.encryptedId}?token=${token}&media=1`);
const streamURL = new URL(
`/api/thumb/${file.encryptedId}?size=1000`,
config.basePath,
);
streamURL.searchParams.set("token", token);
fetch(streamURL, {
headers: {
Range: `bytes=0-${(file.size || 1) - 1}`,
},
})
.then((res) => {
if (!res.ok) {
throw new Error("Could not load image");
}
return res.blob();
})
.then((blob) => {
const urlobject = URL.createObjectURL(blob);
setImgSrc(urlobject);
const timeout = setTimeout(() => {
setImgLoaded(true);
clearTimeout(timeout);
}, 150); // Add a delay to show the image
});
} catch (error) {
const e = error as Error;
console.error(e);
@@ -62,15 +92,38 @@ export default function PreviewImage({ file }: Props) {
<span className='text-center text-destructive'>{error}</span>
</div>
) : (
<img
src={imgSrc}
alt={file.name}
className='max-h-[70dvh] w-full rounded-[var(--radius)] bg-muted object-contain object-center'
onError={(e) => {
console.error(e);
setError("Could not preview this image, try downloading the file");
}}
/>
<div className='h-fit w-full space-y-3 overflow-hidden rounded-[var(--radius)]'>
<img
src={imgSrc}
alt={file.name}
className={cn(
"h-full max-h-[70dvh] w-full bg-muted object-contain object-center transition",
imgLoaded ? "blur-none" : "animate-pulse blur",
)}
onError={(e) => {
console.error(e);
setError(
"Could not preview this image, try downloading the file",
);
}}
/>
<Alert className='bg-yellow-50 text-yellow-600 dark:bg-yellow-950 dark:text-yellow-500'>
<div className='flex items-start gap-3'>
<Icon
name='TriangleAlert'
className='size-5'
/>
<div className='flex flex-col'>
<AlertTitle>Preview Only</AlertTitle>
<AlertDescription>
This image is a preview and may not be the full resolution.
Please download the file for the full resolution.
</AlertDescription>
</div>
</div>
</Alert>
</div>
)}
</div>
);
+63 -32
View File
@@ -3,11 +3,15 @@
import { useState } from "react";
import { z } from "zod";
import { Schema_File } from "~/schema";
import { cn } from "~/utils";
import Icon from "~/components/Icon";
import { Card, CardContent } from "~/components/ui/card";
import { getFileType } from "~/utils/previewHelper";
import config from "~/config/gIndex.config";
import PreviewAction from "./@preview.action";
import PreviewAudio from "./@preview.audio";
import PreviewDoc from "./@preview.doc";
@@ -35,39 +39,66 @@ export default function FilePreviewLayout({ data, fileType }: Props) {
fileType={fileType}
/>
<CardContent className='p-1.5 pt-0 tablet:p-3 tablet:pt-0'>
<div className='px-3'>
{fileType === "image" ? (
<PreviewImage file={data} />
) : fileType === "audio" ? (
<PreviewAudio file={data} />
) : fileType === "video" ? (
<PreviewVideo file={data} />
) : fileType === "code" ? (
<PreviewRich
file={data}
code
view={view}
{config.apiConfig.streamMaxSize &&
Number(data.size || 0) > config.apiConfig.streamMaxSize ? (
<div
className={cn(
"h-auto min-h-[33dvh] w-full",
"flex flex-grow flex-col items-center justify-center gap-3",
)}
>
<Icon
name='Frown'
size={32}
className='text-muted-foreground'
/>
) : fileType === "text" ? (
<PreviewRich
file={data}
view={view}
/>
) : fileType === "markdown" ? (
<PreviewRich
file={data}
view={view}
/>
) : fileType === "document" ? (
<PreviewDoc file={data} />
) : fileType === "pdf" ? (
<PreviewDoc file={data} />
) : fileType === "manga" ? (
<PreviewManga file={data} />
) : (
<PreviewUnknown />
)}
</div>
<h4 className='text-muted-foreground'>Preview not available</h4>
<p className='text-center text-muted-foreground tablet:text-sm'>
Looks like this file size exceed the preview size limit
</p>
</div>
) : (
/**
* TODO: Might need a better way to handle large files preview
* like manga, pdf, etc
*
* For now it's downloading the whole file and then previewing it
* which is not a good implementation
*/
<div className='px-3'>
{fileType === "image" ? (
<PreviewImage file={data} />
) : fileType === "audio" ? (
<PreviewAudio file={data} />
) : fileType === "video" ? (
<PreviewVideo file={data} />
) : fileType === "code" ? (
<PreviewRich
file={data}
code
view={view}
/>
) : fileType === "text" ? (
<PreviewRich
file={data}
view={view}
/>
) : fileType === "markdown" ? (
<PreviewRich
file={data}
view={view}
/>
) : fileType === "document" ? (
<PreviewDoc file={data} />
) : fileType === "pdf" ? (
<PreviewDoc file={data} />
) : fileType === "manga" ? (
<PreviewManga file={data} />
) : (
<PreviewUnknown />
)}
</div>
)}
</CardContent>
</Card>
<PreviewAction file={data} />
+180 -49
View File
@@ -1,12 +1,14 @@
"use client";
import JSZip from "jszip";
import { useEffect, useState } from "react";
import { AsyncUnzipInflate, Unzip } from "fflate";
import { useEffect, useRef, useState } from "react";
import { z } from "zod";
import { Schema_File } from "~/schema";
import { cn } from "~/utils";
import Icon from "~/components/Icon";
import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert";
import { Button } from "~/components/ui/button";
import {
Carousel,
CarouselApi,
@@ -15,9 +17,12 @@ import {
CarouselNext,
CarouselPrevious,
} from "~/components/ui/carousel";
import { Progress } from "~/components/ui/progress";
import useMediaQuery from "~/hooks/useMediaQuery";
import config from "~/config/gIndex.config";
import { CreateDownloadToken } from "./actions";
type Props = {
@@ -31,40 +36,101 @@ export default function PreviewManga({ file }: Props) {
const [currentImage, setCurrentImage] = useState<number>(1);
const [viewSize, setViewSize] = useState<"fit" | "full">("fit");
const [api, setApi] = useState<CarouselApi>();
const [loadedPercent, setLoadedPercent] = useState<number>(0);
const [loadFirstX, setLoadFirstX] = useState<number>(5); // 5MB
const abortController = useRef<AbortController>(new AbortController());
const isDesktop = useMediaQuery("(min-width: 768px)");
useEffect(() => {
(async () => {
try {
if (!abortController.current) {
abortController.current = new AbortController();
}
if (!file.encryptedWebContentLink) {
setError("No video to preview");
return;
}
const token = await CreateDownloadToken();
const manga = await fetch(
`/api/download/${file.encryptedId}?token=${token}`,
const streamURL = new URL(
`/api/stream/${file.encryptedId}`,
config.basePath,
);
const archiveBlob = await manga.blob();
const zipData = await JSZip.loadAsync(archiveBlob);
streamURL.searchParams.set("token", token);
const tempArray: { name: string; blob: string }[] = [];
const files = Object.values(zipData.files);
for (const file of files) {
const f = zipData.file(file.name);
if (!f) continue;
const blob = await f.async("blob");
const reader = new FileReader();
reader.onload = () => {
setImages((prev) => {
const exist = prev.find((p) => p.name === file.name);
if (exist) return prev;
return [
...prev,
{ name: file.name, blob: reader.result as string },
];
});
};
reader.readAsDataURL(blob);
const bufferStream = await fetch(streamURL, {
signal: abortController.current.signal,
headers: {
Range: `bytes=0-${
Math.min(Number(file.size || 1), loadFirstX * 1024 * 1024) - 1
}`,
},
});
const contentLength = bufferStream.headers.get("Content-Length");
const totalBytes = parseInt(contentLength || "0", 10);
const reader = bufferStream.body?.getReader();
if (reader) {
const chunks: Uint8Array[] = [];
let receivedLength = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (value) {
chunks.push(value);
receivedLength += value.length;
const percent = (receivedLength / totalBytes) * 100;
setLoadedPercent((prev) => {
if (percent > prev) return percent;
return prev;
});
}
}
const buffer = new Uint8Array(receivedLength);
let position = 0;
for (const chunk of chunks) {
buffer.set(chunk, position);
position += chunk.length;
}
const unzip = new Unzip((file) => {
file.ondata = (err, data, final) => {
if (err) throw err;
// Check if the file is an image
if (
!file.name
.toLowerCase()
.includes(".jpg" || ".jpeg" || ".png" || ".gif" || ".webp")
)
return;
if (!final) return; // Skip if not fully loaded
const blob = new Blob([data]);
const reader = new FileReader();
reader.onload = () => {
setImages((prev) => {
const exist = prev.find((p) => p.name === file.name);
if (exist) return prev;
return [
...prev,
{ name: file.name, blob: reader.result as string },
];
});
};
reader.readAsDataURL(blob);
};
file.start();
});
unzip.register(AsyncUnzipInflate);
unzip.push(buffer);
}
} catch (error) {
const e = error as Error;
@@ -74,7 +140,12 @@ export default function PreviewManga({ file }: Props) {
setLoading(false);
}
})();
}, [file]);
// return () => {
// abortController.current.abort("Cancelled by user");
// };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!api) return;
@@ -99,7 +170,31 @@ export default function PreviewManga({ file }: Props) {
size={32}
className='animate-spin text-foreground'
/>
<p>Loading manga content...</p>
<p>Please wait while we downloading the content for preview</p>
<div className='flex w-full max-w-sm flex-col gap-1'>
<Progress
className='h-2 w-full'
value={loadedPercent}
/>
<span className='text-center text-xs text-muted-foreground'>
{Math.round(loadedPercent)}% loaded
</span>
</div>
<Button
size={"sm"}
variant={"secondary"}
onClick={async () => {
abortController.current.abort("Cancelled by user");
const timeout = setTimeout(() => {
setLoading(false);
setError("Preview cancelled by user");
clearTimeout(timeout);
}, 100);
}}
>
Cancel Preview
</Button>
</div>
) : error ? (
<div className='flex h-full flex-col items-center justify-center gap-3'>
@@ -111,7 +206,7 @@ export default function PreviewManga({ file }: Props) {
<span className='text-center text-destructive'>{error}</span>
</div>
) : (
<div className='flex flex-col gap-3'>
<div className='flex w-full flex-col items-center justify-center gap-3'>
<div className='h-full w-full tablet:px-8'>
<Carousel
className={cn(
@@ -124,24 +219,28 @@ export default function PreviewManga({ file }: Props) {
setApi={setApi}
>
<CarouselContent className='h-full w-full'>
{images.map((image, index) => (
<CarouselItem
key={index}
className='flex h-full flex-col items-center justify-center'
>
<img
src={image.blob}
alt={`${image.name} - Page ${index + 1}`}
className={cn(
"w-full object-contain",
viewSize === "fit" ? "h-full max-h-[70dvh]" : "h-full",
)}
/>
<span className='muted text-center text-xs'>
{image.name}
</span>
</CarouselItem>
))}
{images
.sort((a, b) => a.name.localeCompare(b.name))
.map((image, index) => (
<CarouselItem
key={index}
className='flex h-full flex-col items-center justify-center'
>
<img
src={image.blob}
alt={`${image.name} - Page ${index + 1}`}
className={cn(
"w-full object-contain",
viewSize === "fit"
? "h-full max-h-[70dvh]"
: "h-full",
)}
/>
<span className='muted text-center text-xs'>
{image.name}
</span>
</CarouselItem>
))}
</CarouselContent>
{isDesktop ? (
<>
@@ -151,11 +250,27 @@ export default function PreviewManga({ file }: Props) {
) : null}
</Carousel>
</div>
<div className='flex w-full items-center justify-end gap-1.5'>
<span className='muted text-sm'>
<div className='flex w-full items-center justify-end gap-3'>
<span className='text-primary-foreground'>
{currentImage}/{images.length}
</span>
<div
<Button
size='icon'
variant={"ghost"}
className='aspect-square size-8 p-0.5'
onClick={() =>
setViewSize((prev) => {
if (prev === "fit") return "full";
return "fit";
})
}
>
<Icon
name={viewSize === "fit" ? "Maximize" : "Minimize"}
size={16}
/>
</Button>
{/* <div
className='relative z-10 cursor-pointer p-0.5 text-foreground/80 transition hover:text-foreground'
onClick={() => setViewSize(viewSize === "fit" ? "full" : "fit")}
>
@@ -163,8 +278,24 @@ export default function PreviewManga({ file }: Props) {
name={viewSize === "fit" ? "Maximize" : "Minimize"}
size={14}
/>
</div>
</div> */}
</div>
<Alert className='bg-yellow-50 text-yellow-600 dark:bg-yellow-950 dark:text-yellow-500'>
<div className='flex items-start gap-3'>
<Icon
name='TriangleAlert'
className='size-5'
/>
<div className='flex flex-col'>
<AlertTitle>Preview Only</AlertTitle>
<AlertDescription>
We only load the first {loadFirstX}MB of the file for preview.
Please download the file for full content.
</AlertDescription>
</div>
</div>
</Alert>
</div>
)}
</div>
+12 -9
View File
@@ -36,7 +36,7 @@ export default function PreviewRich({ file, code, view }: Props) {
// if (code) {
// setContent(`\`\`\`${file.fileExtension}\n${text}\`\`\``);
// } else {
setContent(text);
setContent(text.trim());
// }
} catch (error) {
const e = error as Error;
@@ -91,7 +91,7 @@ export default function PreviewRich({ file, code, view }: Props) {
<Markdown
content={
code && view === "markdown"
? `\`\`\`${file.fileExtension}\n${content}\`\`\``
? `\`\`\`${file.fileExtension}\n${content}`
: content
}
view={view}
@@ -99,23 +99,26 @@ export default function PreviewRich({ file, code, view }: Props) {
</div>
<div
className={cn(
"absolute bottom-0 z-10 flex w-full items-center justify-center py-3 transition",
expand
? "pointer-events-none opacity-0"
: "pointer-events-auto opacity-100",
"bottom-0 z-10 flex w-full items-center justify-center py-3 transition",
expand ? "relative" : "absolute",
// expand
// ? "pointer-events-none opacity-0"
// : "pointer-events-auto opacity-100",
)}
>
<Button
size={"sm"}
variant={"secondary"}
className='gap-1.5'
onClick={() => {
setExpand(true);
setExpand((prev) => !prev);
}}
>
<Icon
name='ChevronDown'
name={expand ? "ChevronUp" : "ChevronDown"}
size={16}
/>
Expand
{expand ? "Collapse" : "Expand"}
</Button>
</div>
</div>
+24 -24
View File
@@ -12,6 +12,21 @@ import { CreateDownloadToken } from "./actions";
const Plyr = dynamic(() => import("plyr-react"), {
ssr: false,
loading: () => (
<div
className={cn(
"h-auto min-h-[50dvh] w-full",
"flex flex-grow flex-col items-center justify-center gap-3",
)}
>
<Icon
name='LoaderCircle'
size={32}
className='animate-spin text-foreground'
/>
<p>Loading player...</p>
</div>
),
});
type Props = {
@@ -78,6 +93,7 @@ export default function PreviewVideo({ file }: Props) {
size: file.size,
},
],
poster: `/api/thumb/${file.encryptedId}?size=1000`,
}}
// crossOrigin='anonymous'
options={{
@@ -97,33 +113,17 @@ export default function PreviewVideo({ file }: Props) {
"download",
"fullscreen",
],
volume: 0.5,
muted: false,
loop: { active: false },
speed: { selected: 1, options: [0.5, 1, 1.5, 2] },
keyboard: {
focused: true,
global: true,
},
}}
/>
</div>
// <ReactPlayer
// key={file.encryptedId}
// url={videoSrc}
// controls
// pip
// wrapper={({ children }) => (
// <div className='h-[60dvh] w-full overflow-hidden rounded-[var(--radius)] bg-muted'>
// {children}
// </div>
// )}
// style={{
// width: "100%",
// height: "100%",
// maxHeight: "60vh",
// }}
// onError={(error) => {
// console.error(error.message);
// if (error instanceof Error) {
// setError(error.message);
// } else {
// setError("Failed to load video. (Probably not supported?)");
// }
// }}
// />
)}
</div>
);