diff --git a/README.md b/README.md index 0a9c51e..6db2434 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,11 @@ For now, I don't have any plan to implement this, because I think it's not neces While I think it's important, I don't have any plan to implement this for now. -### ~Encryption cause error~ +### TS video file doesn't recognized + +Somehow google return the mime type as code instead of video. I don't know how to fix this, so for now you can't preview the TS video file. + +### ~~Encryption cause error~~ ~~It seems the configurator are generating wrong encrypted folder ID, so it will cause error when you try to access the folder.~~ Should be fixed on v2.0.4, waiting for feedback / confirmation diff --git a/bun.lockb b/bun.lockb index d1a8d9d..454367e 100644 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index a32e083..a6d5cad 100644 --- a/package.json +++ b/package.json @@ -15,14 +15,14 @@ "dependencies": { "@hookform/resolvers": "^3.10.0", "@oslojs/encoding": "^1.1.0", - "@radix-ui/react-accordion": "^1.1.2", + "@radix-ui/react-accordion": "^1.2.3", "@radix-ui/react-alert-dialog": "^1.1.2", "@radix-ui/react-aspect-ratio": "^1.0.3", "@radix-ui/react-avatar": "^1.0.4", "@radix-ui/react-checkbox": "^1.1.3", "@radix-ui/react-collapsible": "^1.0.3", "@radix-ui/react-context-menu": "^2.2.4", - "@radix-ui/react-dialog": "^1.1.4", + "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dropdown-menu": "^2.1.2", "@radix-ui/react-hover-card": "^1.0.7", "@radix-ui/react-label": "^2.1.1", @@ -33,25 +33,26 @@ "@radix-ui/react-radio-group": "^1.1.3", "@radix-ui/react-scroll-area": "^1.2.2", "@radix-ui/react-select": "^2.1.2", - "@radix-ui/react-separator": "^1.1.0", + "@radix-ui/react-separator": "^1.1.2", "@radix-ui/react-slider": "^1.2.1", - "@radix-ui/react-slot": "^1.1.1", + "@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-switch": "^1.1.2", "@radix-ui/react-tabs": "^1.1.1", "@radix-ui/react-toast": "^1.1.5", "@radix-ui/react-toggle": "^1.0.3", "@radix-ui/react-toggle-group": "^1.0.4", - "@radix-ui/react-tooltip": "^1.1.4", + "@radix-ui/react-tooltip": "^1.1.8", "@tanstack/react-virtual": "^3.11.2", "@vidstack/react": "^1.12.12", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "1.0.0", "embla-carousel-react": "^8.5.1", + "embla-carousel-wheel-gestures": "^8.0.1", "fflate": "^0.8.2", "framer-motion": "^11.13.3", "googleapis": "^144.0.0", - "lucide-react": "^0.468.0", + "lucide-react": "^0.477.0", "next": "^15.1.6", "next-themes": "^0.4.4", "nextjs-toploader": "^1.6.11", diff --git a/src/actions/files.ts b/src/actions/files.ts index d070e7c..f748a02 100644 --- a/src/actions/files.ts +++ b/src/actions/files.ts @@ -9,6 +9,8 @@ import { Schema_File, Schema_File_Shortcut } from "~/types/schema"; import config from "config"; +import { ValidatePaths } from "./paths"; + /** * List files in a folder * @param {object} options @@ -372,3 +374,90 @@ export async function GetContent(id: string): Promise[]>> { + const pathIds = await ValidatePaths(paths); + if (!pathIds.success) + return { + success: false, + message: "Failed to validate paths", + error: pathIds.error, + }; + const folderPaths = pathIds.data.filter((item) => item.mimeType === "application/vnd.google-apps.folder"); + + const parentId = folderPaths[folderPaths.length - 1]?.id ?? config.apiConfig.rootFolder; + const isSharedDrive = !!(config.apiConfig.isTeamDrive && config.apiConfig.sharedDrive); + const decryptedParentId = await encryptionService.decrypt(parentId); + const decryptedSharedDrive = isSharedDrive + ? await encryptionService.decrypt(config.apiConfig.sharedDrive!) + : undefined; + + const filterName = config.apiConfig.hiddenFiles.map((item) => `not name = '${item}'`).join(" and "); + const filterQuery: string = [ + ...config.apiConfig.defaultQuery, + `'${decryptedParentId}' in parents`, + filterName, + "(mimeType contains 'video' or mimeType contains 'audio')", + ].join(" and "); + + const { data } = await gdrive.files.list({ + q: filterQuery, + fields: `files(${config.apiConfig.defaultField})`, + orderBy: config.apiConfig.defaultOrder, + pageSize: 100, + ...(decryptedSharedDrive && { + supportsAllDrives: true, + includeItemsFromAllDrives: true, + driveId: decryptedSharedDrive, + corpora: "drive", + }), + }); + if (!data.files?.length) return { success: true, message: "No siblings media found", data: [] }; + + const files: z.infer[] = []; + for (const file of data.files) { + files.push({ + encryptedId: await encryptionService.encrypt(file.id!), + encryptedWebContentLink: file.webContentLink ? await encryptionService.encrypt(file.webContentLink) : undefined, + name: file.name!, + mimeType: file.mimeType!, + trashed: file.trashed ?? false, + modifiedTime: new Date(file.modifiedTime!).toLocaleDateString(), + fileExtension: file.fileExtension ?? undefined, + size: file.size ? Number(file.size) : undefined, + thumbnailLink: file.thumbnailLink ?? undefined, + imageMediaMetadata: file.imageMediaMetadata + ? { + width: Number(file.imageMediaMetadata.width), + height: Number(file.imageMediaMetadata.height), + rotation: Number(file.imageMediaMetadata.rotation ?? 0), + } + : undefined, + videoMediaMetadata: file.videoMediaMetadata + ? { + durationMillis: Number(file.videoMediaMetadata.durationMillis), + height: Number(file.videoMediaMetadata.height), + width: Number(file.videoMediaMetadata.width), + } + : undefined, + }); + } + + const parsed = Schema_File.array().safeParse(files); + if (!parsed.success) + return { + success: false, + message: "Failed to parse siblings media", + error: parsed.error.message, + }; + + return { + success: true, + message: "Siblings media fetched", + data: parsed.data, + }; +} diff --git a/src/app/[...rest]/page.tsx b/src/app/[...rest]/page.tsx index 5b2f3c9..07f7571 100644 --- a/src/app/[...rest]/page.tsx +++ b/src/app/[...rest]/page.tsx @@ -1,5 +1,6 @@ import { type Metadata, type ResolvedMetadata } from "next"; import { notFound } from "next/navigation"; +import { type z } from "zod"; import { FileActions, FileBreadcrumb, FileExplorerLayout, FileReadme } from "~/components/explorer"; import { ErrorComponent, Password } from "~/components/layout"; @@ -9,7 +10,9 @@ import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { getFileType } from "~/lib/previewHelper"; import { formatPathToBreadcrumb } from "~/lib/utils"; -import { GetBanner, GetFile, GetReadme, ListFiles } from "~/actions/files"; +import { type Schema_File } from "~/types/schema"; + +import { GetBanner, GetFile, GetReadme, GetSiblingsMedia, ListFiles } from "~/actions/files"; import { CheckPagePassword } from "~/actions/password"; import { ValidatePaths } from "~/actions/paths"; import { CreateFileToken } from "~/actions/token"; @@ -131,6 +134,14 @@ export default async function RestPage({ params }: Props) { const token = await CreateFileToken(file.data); if (!token.success) return ; + // Check if file is media (video / audio) + let playlistFiles: z.infer[] = []; + if (file.data.mimeType.includes("video") || file.data.mimeType.includes("audio")) { + const sib = await GetSiblingsMedia(rest); + if (!sib.success) return ; + playlistFiles = sib.data; + } + return ( ); diff --git a/src/app/api/test/fileSize/route.ts b/src/app/api/test/fileSize/route.ts new file mode 100644 index 0000000..2f5ad7f --- /dev/null +++ b/src/app/api/test/fileSize/route.ts @@ -0,0 +1,111 @@ +import { type NextRequest, NextResponse } from "next/server"; +import stream from "stream"; +import { promisify } from "util"; + +import { gdrive } from "~/lib/utils.server"; + +const pipeline = promisify(stream.pipeline); +const fileId = "1KvMU9sy8fQKKmVKU5xf_u3Y2JZefe0kt"; + +export async function GET(request: NextRequest) { + try { + const start = Date.now(); + console.log("Fetching metadata", Date.now() - start); + const meta = await gdrive.files.get({ + fileId, + fields: "size,mimeType,name", + supportsAllDrives: true, + }); + const { data: metadata } = meta; + + console.log("Fetching file", Date.now() - start); + // const data = await gdrive.files.get( + // { + // fileId: fileId, + // alt: "media", + // acknowledgeAbuse: true, + // }, + // { + // responseType: "stream", + // }, + // ); + + console.log("Setting headers", Date.now() - start); + const headers = new Headers(); + headers.set("Content-Type", metadata.mimeType ?? "application/octet-stream"); + if (metadata.size) headers.set("Content-Length", metadata.size.toString()); + headers.set("Content-Disposition", `attachment; filename="${metadata.name}"`); + + // console.log("Streaming file", Date.now() - start); + // const contentStream = data.data; + // const webStream = new ReadableStream({ + // async start(controller) { + // contentStream.on("data", (chunk) => { + // controller.enqueue(chunk); + // }); + // contentStream.on("end", () => { + // controller.close(); + // }); + // contentStream.on("error", (err) => { + // console.error(err); + // controller.error(err); + // }); + // }, + // cancel() { + // contentStream.destroy(); + // }, + // }); + + // console.log("Returning response", Date.now() - start); + return new Response( + new ReadableStream({ + async start(controller) { + try { + gdrive.files.get( + { + fileId: fileId, + alt: "media", + acknowledgeAbuse: true, + supportsAllDrives: true, + }, + { + responseType: "stream", + }, + (err, res) => { + if (err) throw err; + res?.data + .on("data", (chunk) => { + controller.enqueue(chunk); + }) + .on("end", () => { + controller.close(); + }) + .on("error", (err) => { + console.error(err); + controller.error(err); + }); + }, + ); + } catch (error) { + controller.error(error); + console.error(error); + } + }, + }), + { + headers, + }, + ); + } catch (error) { + const e = error as Error; + console.error(e); + return NextResponse.json( + { + error: e.message, + }, + { + status: 500, + }, + ); + } +} diff --git a/src/components/preview/Media.tsx b/src/components/preview/Media.tsx index 6e9208c..a9a1213 100644 --- a/src/components/preview/Media.tsx +++ b/src/components/preview/Media.tsx @@ -39,11 +39,11 @@ type Props = { type: "video" | "audio"; }; export default function PreviewMedia({ file, type }: Props) { - const loading = useLoading(); const player = useRef(null); const [canPlay, setCanPlay] = useState(false); const [isLoop, setIsLoop] = useState(false); + const loading = useLoading(); const smallAudioLayoutQuery = useCallback(({ width }) => { return width < 576; @@ -53,7 +53,7 @@ export default function PreviewMedia({ file, type }: Props) { }, []); return ( -
+
{loading ? ( ) : ( @@ -65,7 +65,6 @@ export default function PreviewMedia({ file, type }: Props) { type: file.mimeType as AudioMimeType | VideoMimeType, }} loop={isLoop} - autoPlay playsInline crossOrigin viewType={type === "audio" ? "audio" : "video"} diff --git a/src/components/preview/MediaPlaylistLayout.tsx b/src/components/preview/MediaPlaylistLayout.tsx new file mode 100644 index 0000000..946b1eb --- /dev/null +++ b/src/components/preview/MediaPlaylistLayout.tsx @@ -0,0 +1,296 @@ +"use client"; + +import Link from "next/link"; +import { Fragment, useEffect, useMemo, useState } from "react"; +import { type z } from "zod"; + +import { useResponsive } from "~/context/responsiveContext"; +import useLoading from "~/hooks/useLoading"; +import { getPreviewIcon } from "~/lib/previewHelper"; +import { cn } from "~/lib/utils"; + +import { type Schema_File } from "~/types/schema"; + +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "../ui/accordion"; +import { Badge } from "../ui/badge"; +import { Button } from "../ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"; +import { + Carousel, + type CarouselApi, + CarouselContent, + CarouselItem, + CarouselNext, + CarouselPrevious, +} from "../ui/carousel"; +import Icon from "../ui/icon"; +import { Skeleton } from "../ui/skeleton"; + +type MediaPlaylistLayoutProps = { + type?: "separated" | "inside"; + paths: string[]; + currentItem: z.infer; + playlist: z.infer[]; +}; + +export default function MediaPlaylistLayout({ + type = "separated", + paths, + currentItem, + playlist, +}: MediaPlaylistLayoutProps) { + const loading = useLoading(); + const { isDesktop } = useResponsive(); + + const [api, setApi] = useState(); + const [currentIndex, setCurrentIndex] = useState(0); + const [totalIndex, setTotalIndex] = useState(0); + + const uniquePlaylist = useMemo[]>( + () => playlist.filter((item) => `${item.name}#${item.size}` !== `${currentItem.name}#${currentItem.size}`), + [currentItem, playlist], + ); + + useEffect(() => { + if (!api) return; + + setTotalIndex(api.scrollSnapList().length); + setCurrentIndex(api.selectedScrollSnap()); + + api.on("select", () => { + setCurrentIndex(api.selectedScrollSnap()); + }); + }, [api]); + + // <= 1, because it always include current media + if (!uniquePlaylist.length) return null; + if (type === "separated") + return ( + + + Playlist + + + {loading ? ( +
+
+ + + {isDesktop && } +
+
+ ) : ( + item.encryptedId === currentItem.encryptedId) ?? 0) + 1, + containScroll: "keepSnaps", + }} + > + + {playlist.map((item) => { + const isCurrent = `${item.name}#${item.size}` === `${currentItem.name}#${currentItem.size}`; + const itemUrl = paths.slice(0, paths.length - 1).join("/") + `/${encodeURIComponent(item.name)}`; + const Wrapper = ({ children }: { children: React.ReactNode }) => + isCurrent ? {children} : {children}; + + return ( + + +
+ {isCurrent && Playing} + {item.mimeType.includes("video") ? ( + {`Preview + ) : ( +
+ +
+ )} +
+

+ {item.name} +

+
+
+
+
+ ); + })} +
+ + +
+ )} +
+
+ ); + if (type === "inside") + return ( + + + +
+ + Playlist +
+
+ + {loading ? ( +
+ + + + +
+ ) : ( +
+ {/* Current media */} + + + {/* Playlist */} + + + {uniquePlaylist.map((item) => { + const itemUrl = paths.slice(0, paths.length - 1).join("/") + `/${encodeURIComponent(item.name)}`; + + return ( + + + + + + ); + })} + +
+
+ {Array.from({ + length: totalIndex, + }).map((_, index) => ( +
+ ))} +
+ +
+ + + +
+
+ +
+ )} + + + + ); + + return <>; +} + +type ItemProps = { + item: z.infer; + isCurrent: boolean; + className?: string; +}; +function Item({ item, isCurrent, className }: ItemProps) { + return ( +
+ {isCurrent && Playing} + {item.mimeType.includes("video") ? ( + {`Preview + ) : ( +
+ +
+ )} +
+

+ {item.name} +

+
+
+ ); +} diff --git a/src/components/preview/PreviewLayout.tsx b/src/components/preview/PreviewLayout.tsx index 04c98a2..9edae28 100644 --- a/src/components/preview/PreviewLayout.tsx +++ b/src/components/preview/PreviewLayout.tsx @@ -14,7 +14,7 @@ import { PreviewUnknown, } from "~/components/preview"; import { Button } from "~/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; +import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "~/components/ui/card"; import { type getFileType } from "~/lib/previewHelper"; import { cn } from "~/lib/utils"; @@ -23,29 +23,27 @@ import { type Schema_File } from "~/types/schema"; import config from "config"; +import MediaPlaylistLayout from "./MediaPlaylistLayout"; + type Props = { data: z.infer; fileType: "unknown" | ReturnType; token: string; + playlist: z.infer[]; + paths: string[]; }; -export default function PreviewLayout({ data, fileType, token }: Props) { +export default function PreviewLayout({ data, paths, fileType, token, playlist }: Props) { const [view, setView] = useState<"markdown" | "raw">("markdown"); const PreviewComponent = useMemo(() => { switch (fileType) { case "image": return ; case "video": - return ( - - ); case "audio": return ( ); case "code": @@ -135,6 +133,15 @@ export default function PreviewLayout({ data, fileType, token }: Props) { <>{PreviewComponent} )} + + + + , + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AccordionItem.displayName = "AccordionItem" + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180", + className + )} + {...props} + > + {children} + + + +)) +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)) +AccordionContent.displayName = AccordionPrimitive.Content.displayName + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/src/hooks/use-mobile.tsx b/src/hooks/use-mobile.tsx new file mode 100644 index 0000000..2b0fe1d --- /dev/null +++ b/src/hooks/use-mobile.tsx @@ -0,0 +1,19 @@ +import * as React from "react" + +const MOBILE_BREAKPOINT = 768 + +export function useIsMobile() { + const [isMobile, setIsMobile] = React.useState(undefined) + + React.useEffect(() => { + const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) + const onChange = () => { + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) + } + mql.addEventListener("change", onChange) + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) + return () => mql.removeEventListener("change", onChange) + }, []) + + return !!isMobile +} diff --git a/src/styles/globals.css b/src/styles/globals.css index fc30c8b..dabd7a7 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -177,3 +177,12 @@ body { } @layer base { } + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/tailwind.config.ts b/tailwind.config.ts index 2209800..0f55235 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -1,4 +1,7 @@ +import twTypography from "@tailwindcss/typography"; +import twVidstack from "@vidstack/react/tailwind.cjs"; import type { Config } from "tailwindcss"; +import twAnimate from "tailwindcss-animate"; import tw from "tailwindcss/defaultTheme"; export default { @@ -103,9 +106,9 @@ export default { }, }, plugins: [ - require("tailwindcss-animate"), - require("@tailwindcss/typography"), - require("@vidstack/react/tailwind.cjs")({ + twAnimate, + twTypography, + twVidstack({ selector: ".media-player", prefix: "media", }),