diff --git a/backend/routes/files.ts b/backend/routes/files.ts index 25df8b7..94f38f9 100644 --- a/backend/routes/files.ts +++ b/backend/routes/files.ts @@ -28,9 +28,7 @@ const filesDirList = process.env.FILE_DIRS const route = new Hono() .get("/", zValidator("query", getFilesSchema), async (c) => { const input: z.infer = c.req.query(); - const pathname = (input.path || "").split("/"); - const path = pathname.slice(2).join("/"); - const baseName = pathname[1]; + const { baseName, path, pathname } = getFilePath(input.path); if (!baseName?.length) { return c.json( @@ -42,20 +40,14 @@ const route = new Hono() ); } - const baseDir = filesDirList.find((i) => i.name === baseName)?.path; - if (!baseDir) { - return c.json([]); - } - try { - const cwd = baseDir + "/" + path; - const entities = await fs.readdir(cwd, { withFileTypes: true }); + const entities = await fs.readdir(path, { withFileTypes: true }); const files = entities .filter((e) => !e.name.startsWith(".")) .map((e) => ({ name: e.name, - path: "/" + [baseName, path, e.name].filter(Boolean).join("/"), + path: [pathname, e.name].join("/"), isDirectory: e.isDirectory(), })) .sort((a, b) => { @@ -90,16 +82,11 @@ const route = new Hono() throw new HTTPException(400, { message: "Files is empty!" }); } - const pathSlices = data.path.split("/"); - const baseName = pathSlices[1] || null; - const path = pathSlices.slice(2).join("/"); - const baseDir = filesDirList.find((i) => i.name === baseName)?.path; + const { baseDir, path: targetDir } = getFilePath(data.path); if (!baseDir?.length) { throw new HTTPException(400, { message: "Path not found!" }); } - const targetDir = [baseDir, path].join("/"); - // files.forEach((file) => { // const filepath = targetDir + "/" + file.name; // if (existsSync(filepath)) { @@ -124,15 +111,16 @@ const route = new Hono() const pathSlice = pathname.slice(pathname.indexOf("download") + 1); const baseName = pathSlice[0]; const path = "/" + pathSlice.slice(1).join("/"); + const filename = path.substring(1); try { if (!baseName?.length) { - throw new Error(); + throw new Error("baseName is empty"); } const baseDir = filesDirList.find((i) => i.name === baseName)?.path; if (!baseDir) { - throw new Error(); + throw new Error("baseDir not found"); } const filepath = baseDir + path; @@ -141,7 +129,10 @@ const route = new Hono() if (dlFile) { c.header("Content-Type", "application/octet-stream"); - c.header("Content-Disposition", `attachment; filename="${path}"`); + c.header( + "Content-Disposition", + `attachment; filename="${encodeURIComponent(filename)}"` + ); } else { c.header("Content-Type", getMimeType(filepath)); } @@ -177,23 +168,24 @@ const route = new Hono() return c.body(stream, 206); } catch (err) { + // console.log("err", err); throw new HTTPException(404, { message: "Not Found!" }); } }); -function getFilePath(path: string) { - const pathSlices = path.split("/"); +function getFilePath(path?: string) { + const pathSlices = + path + ?.replace(/\/{2,}/g, "/") + .replace(/\/$/, "") + .split("/") || []; const baseName = pathSlices[1] || null; const filePath = pathSlices.slice(2).join("/"); - const baseDir = filesDirList.find((i) => i.name === baseName)?.path; - if (!baseDir?.length) { - throw new HTTPException(400, { message: "Path not found!" }); - } return { - path: [baseDir, filePath].join("/"), - pathname: ["", baseName, filePath].join("/"), + path: [baseDir || "", filePath].join("/").replace(/\/$/, ""), + pathname: ["", baseName, filePath].join("/").replace(/\/$/, ""), baseName, baseDir, filePath, diff --git a/bun.lockb b/bun.lockb index e89c538..1a77927 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/global.d.ts b/global.d.ts index 2ff1478..81a3413 100644 --- a/global.d.ts +++ b/global.d.ts @@ -2,3 +2,8 @@ declare module "*.svg" { const content: React.FunctionComponent>; export default content; } + +declare module "*.jpg"; +declare module "*.jpeg"; +declare module "*.png"; +declare module "*.webp"; diff --git a/package.json b/package.json index 2b3b22d..f712080 100644 --- a/package.json +++ b/package.json @@ -13,21 +13,26 @@ "dependencies": { "@expo/metro-runtime": "~3.1.3", "@hookform/resolvers": "^3.3.4", + "@miblanchard/react-native-slider": "^2.3.1", "@react-native-async-storage/async-storage": "1.21.0", "@types/react": "~18.2.45", + "base-64": "^1.0.0", "class-variance-authority": "^0.7.0", "dayjs": "^1.11.10", "expo": "~50.0.11", + "expo-av": "~13.10.5", "expo-constants": "~15.4.5", "expo-linking": "~6.2.2", "expo-router": "~3.4.8", "expo-status-bar": "~1.11.1", "hono": "^4.1.0", + "jsmediatags": "^3.9.7", "react": "18.2.0", "react-dom": "18.2.0", "react-hook-form": "^7.51.0", "react-native": "0.73.4", "react-native-circular-progress": "^1.3.9", + "react-native-fs": "^2.20.0", "react-native-modal": "^13.0.1", "react-native-safe-area-context": "4.8.2", "react-native-screens": "~3.29.0", @@ -37,6 +42,7 @@ "react-query": "^3.39.3", "twrnc": "^4.1.0", "typescript": "^5.3.0", + "utf8": "^3.0.0", "xterm": "^5.3.0", "xterm-addon-attach": "^0.9.0", "xterm-addon-fit": "^0.8.0", diff --git a/src/app/apps/files/index.tsx b/src/app/apps/files/index.tsx index 5ce94c1..f36b421 100644 --- a/src/app/apps/files/index.tsx +++ b/src/app/apps/files/index.tsx @@ -4,21 +4,24 @@ import api from "@/lib/api"; import { useAuth } from "@/stores/authStore"; import BackButton from "@ui/BackButton"; import Input from "@ui/Input"; -import { Stack } from "expo-router"; +import { Stack, router, useLocalSearchParams } from "expo-router"; import React from "react"; import { useMutation, useQuery } from "react-query"; -import { openFile } from "./utils"; import FileDrop from "@/components/pages/files/FileDrop"; import { showToast } from "@/stores/toastStore"; import { HStack } from "@ui/Stack"; import Button from "@ui/Button"; import { Ionicons } from "@ui/Icons"; +import FileInlineViewer from "@/components/pages/files/FileInlineViewer"; +import { decodeUrl, encodeUrl } from "@/lib/utils"; +import { FilesContext } from "@/components/pages/files/FilesContext"; const FilesPage = () => { const { isLoggedIn } = useAuth(); const [params, setParams] = useAsyncStorage("files", { path: "", }); + const searchParams = useLocalSearchParams(); const parentPath = params.path.length > 0 ? params.path.split("/").slice(0, -1).join("/") @@ -56,8 +59,12 @@ const FilesPage = () => { } }; + if (!isLoggedIn) { + return null; + } + return ( - <> + , title: "Files" }} /> @@ -71,6 +78,13 @@ const FilesPage = () => { variant="outline" onPress={() => setParams({ ...params, path: parentPath })} /> + + + ); +}; + +const FileInlineViewer = ({ path, onClose }: Props) => { + const filename = path?.split("/").pop(); + + return ( + + + + diff --git a/src/components/pages/files/FilesContext.tsx b/src/components/pages/files/FilesContext.tsx new file mode 100644 index 0000000..b66549d --- /dev/null +++ b/src/components/pages/files/FilesContext.tsx @@ -0,0 +1,12 @@ +import { FileItem } from "@/types/files"; +import { createContext, useContext } from "react"; + +type FilesContextType = { + files: FileItem[]; +}; + +export const FilesContext = createContext({ + files: [], +}); + +export const useFilesContext = () => useContext(FilesContext); diff --git a/src/components/ui/AudioPlayer.tsx b/src/components/ui/AudioPlayer.tsx new file mode 100644 index 0000000..5502b5b --- /dev/null +++ b/src/components/ui/AudioPlayer.tsx @@ -0,0 +1,196 @@ +import { AVPlaybackStatusSuccess, Audio } from "expo-av"; +import { useEffect, useRef, useState } from "react"; +import jsmediatags from "jsmediatags/build2/jsmediatags"; +import { MediaTags } from "@/types/mediaTags"; +import Box from "./Box"; +import Text from "./Text"; +import { base64encode, cn, encodeUrl, getFilename } from "@/lib/utils"; +import { Image } from "react-native"; +import { useFilesContext } from "../pages/files/FilesContext"; +import { HStack } from "./Stack"; +import Button from "./Button"; +import { Ionicons } from "./Icons"; +import { router } from "expo-router"; +import { Slider } from "@miblanchard/react-native-slider"; +import bgImage from "@/assets/images/audioplayer-bg.jpeg"; + +type Props = { + path: string; + uri: string; +}; + +const AudioPlayer = ({ path, uri }: Props) => { + const { files } = useFilesContext(); + const soundRef = useRef(null); + const [curFileIdx, setFileIdx] = useState(-1); + const [status, setStatus] = useState(null); + const [mediaTags, setMediaTags] = useState(null); + const filename = getFilename(decodeURIComponent(uri)); + + const playNext = (inc = 1) => { + if (!files.length || curFileIdx < 0) { + return; + } + + const fileIdx = (curFileIdx + inc) % files.length; + const file = files[fileIdx]; + // setPlayback({ uri: getFileUrl(file), path: file.path }); + router.setParams({ view: encodeUrl(file.path) }); + }; + + const onPlaybackEnd = () => { + playNext(); + }; + + useEffect(() => { + if (!files?.length) { + return; + } + + async function play() { + try { + const { sound } = await Audio.Sound.createAsync({ uri }); + soundRef.current = sound; + sound.setOnPlaybackStatusUpdate((st: AVPlaybackStatusSuccess) => { + setStatus(st as any); + if (st.didJustFinish) { + onPlaybackEnd(); + } + }); + + await sound.playAsync(); + + if (soundRef.current !== sound) { + await sound.unloadAsync(); + } + } catch (err) { + if (err instanceof DOMException) { + if (err.name === "NotSupportedError") { + setTimeout(onPlaybackEnd, 3000); + } + } + } + } + + const fileIdx = files.findIndex((file) => path === file.path); + setFileIdx(fileIdx); + + play(); + + const tagsReader = new jsmediatags.Reader(uri + "&dl=true"); + setMediaTags(null); + + tagsReader.read({ + onSuccess: (result: any) => { + const mediaTagsResult = { ...result }; + + if (result?.tags?.picture) { + const { data, format } = result.tags.picture; + let base64String = ""; + for (let i = 0; i < data.length; i++) { + base64String += String.fromCharCode(data[i]); + } + mediaTagsResult.picture = `data:${format};base64,${base64encode( + base64String + )}`; + delete data?.tags?.picture; + } + + setMediaTags(mediaTagsResult); + }, + }); + + return () => { + soundRef.current?.unloadAsync(); + soundRef.current = null; + }; + }, [uri, path, files]); + + return ( + + + + + + + {mediaTags?.picture ? ( + + ) : null} + + Now Playing + + {mediaTags?.tags?.title || filename} + + {mediaTags?.tags?.artist ? ( + + {mediaTags.tags.artist} + + ) : null} + + { + if (!soundRef.current) { + return; + } + + if (!status?.isPlaying) { + await soundRef.current.playAsync(); + } + + const [progress] = value; + const pos = (progress / 100.0) * (status?.durationMillis || 0); + soundRef.current.setPositionAsync(pos); + }} + /> + + +