mirror of
https://github.com/khairul169/home-lab.git
synced 2026-10-02 17:13:57 +07:00
feat: add inline file viewer, add audio player
This commit is contained in:
@@ -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 (
|
||||
<>
|
||||
<FilesContext.Provider value={{ files: data }}>
|
||||
<Stack.Screen
|
||||
options={{ headerLeft: () => <BackButton />, title: "Files" }}
|
||||
/>
|
||||
@@ -71,6 +78,13 @@ const FilesPage = () => {
|
||||
variant="outline"
|
||||
onPress={() => setParams({ ...params, path: parentPath })}
|
||||
/>
|
||||
<Button
|
||||
icon={<Ionicons name="home-outline" />}
|
||||
className="px-3 border-gray-300"
|
||||
labelClasses="text-gray-500"
|
||||
variant="outline"
|
||||
onPress={() => setParams({ ...params, path: "" })}
|
||||
/>
|
||||
<Input
|
||||
placeholder="/"
|
||||
value={params.path}
|
||||
@@ -86,11 +100,22 @@ const FilesPage = () => {
|
||||
if (file.isDirectory) {
|
||||
return setParams({ ...params, path: file.path });
|
||||
}
|
||||
openFile(file);
|
||||
router.push("/apps/files?view=" + encodeUrl(file.path));
|
||||
}}
|
||||
/>
|
||||
</FileDrop>
|
||||
</>
|
||||
|
||||
<FileInlineViewer
|
||||
path={decodeUrl(searchParams.view as string)}
|
||||
onClose={() => {
|
||||
if (router.canGoBack()) {
|
||||
router.back();
|
||||
} else {
|
||||
router.replace("/apps/files");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FilesContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -2,14 +2,15 @@ import { API_BASEURL } from "@/lib/constants";
|
||||
import authStore from "@/stores/authStore";
|
||||
import { FileItem } from "@/types/files";
|
||||
|
||||
export function openFile(file: FileItem, dl = false) {
|
||||
export function openFile(file: FileItem | string, dl = false) {
|
||||
const url = getFileUrl(file, dl);
|
||||
window.open(url, "_blank");
|
||||
}
|
||||
|
||||
export function getFileUrl(file: FileItem, dl = false) {
|
||||
const url = new URL(API_BASEURL + "/files/download" + file.path);
|
||||
export function getFileUrl(file: FileItem | string, dl = false) {
|
||||
const filepath = typeof file === "string" ? file : file.path;
|
||||
const url = new URL(API_BASEURL + "/files/download" + filepath);
|
||||
url.searchParams.set("token", authStore.getState().token);
|
||||
dl && url.searchParams.set("dlmode", "true");
|
||||
dl && url.searchParams.set("dl", "true");
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 81 KiB |
@@ -0,0 +1,97 @@
|
||||
import { cn, getFileType } from "@/lib/utils";
|
||||
import Box from "@ui/Box";
|
||||
import Button from "@ui/Button";
|
||||
import { Ionicons } from "@ui/Icons";
|
||||
import { HStack } from "@ui/Stack";
|
||||
import Text from "@ui/Text";
|
||||
import React, { useRef } from "react";
|
||||
import Modal from "react-native-modal";
|
||||
import { Video, ResizeMode } from "expo-av";
|
||||
import { getFileUrl, openFile } from "@/app/apps/files/utils";
|
||||
import { Image } from "react-native";
|
||||
import AudioPlayer from "@ui/AudioPlayer";
|
||||
|
||||
type Props = {
|
||||
path?: string | null;
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
const FileViewer = ({ path }: Pick<Props, "path">) => {
|
||||
const videoPlayerRef = useRef<Video>(null!);
|
||||
const fileType = getFileType(path);
|
||||
const uri = getFileUrl(path);
|
||||
|
||||
if (fileType === "video") {
|
||||
return (
|
||||
<Video
|
||||
ref={videoPlayerRef}
|
||||
style={cn("w-full flex-1 overflow-hidden relative")}
|
||||
videoStyle={cn("absolute top-0 left-0 w-full h-full")}
|
||||
source={{ uri }}
|
||||
useNativeControls
|
||||
resizeMode={ResizeMode.CONTAIN}
|
||||
shouldPlay
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (fileType === "audio") {
|
||||
return <AudioPlayer path={path} uri={uri} />;
|
||||
}
|
||||
|
||||
if (fileType === "image") {
|
||||
return (
|
||||
<Image
|
||||
source={{ uri }}
|
||||
style={cn("w-full flex-1")}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className="w-full flex-1 flex flex-col items-center justify-center">
|
||||
<Button onPress={() => openFile(path)}>Open File</Button>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const FileInlineViewer = ({ path, onClose }: Props) => {
|
||||
const filename = path?.split("/").pop();
|
||||
|
||||
return (
|
||||
<Modal isVisible={!!path} onBackButtonPress={onClose} style={cn("m-0")}>
|
||||
<Box className="flex-1 w-full bg-gray-950">
|
||||
<HStack className="gap-2 p-2 bg-gray-900 relative z-10">
|
||||
<Button
|
||||
icon={<Ionicons name="arrow-back" />}
|
||||
iconClassName="text-white"
|
||||
onPress={onClose}
|
||||
variant="ghost"
|
||||
/>
|
||||
<Text className="text-white flex-1" numberOfLines={1}>
|
||||
{filename}
|
||||
</Text>
|
||||
|
||||
<Button
|
||||
icon={<Ionicons name="download-outline" />}
|
||||
iconClassName="text-white text-xl"
|
||||
className="px-3"
|
||||
onPress={() => openFile(path, true)}
|
||||
variant="ghost"
|
||||
/>
|
||||
<Button
|
||||
icon={<Ionicons name="open-outline" />}
|
||||
iconClassName="text-white text-xl"
|
||||
className="px-3"
|
||||
onPress={() => openFile(path)}
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
<FileViewer path={path} />
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileInlineViewer;
|
||||
@@ -87,4 +87,4 @@ const FileItemList = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default FileList;
|
||||
export default React.memo(FileList);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { cn } from "@/lib/utils";
|
||||
import ActionSheet from "@ui/ActionSheet";
|
||||
import { HStack } from "@ui/Stack";
|
||||
import Button from "@ui/Button";
|
||||
import { openFile } from "@/app/apps/files/utils";
|
||||
|
||||
type Store = {
|
||||
isVisible: boolean;
|
||||
@@ -27,6 +28,11 @@ const FileMenu = () => {
|
||||
const { isVisible, file } = useStore(store);
|
||||
const onClose = () => store.setState({ isVisible: false });
|
||||
|
||||
const onDownload = () => {
|
||||
openFile(file, true);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<ActionSheet isVisible={isVisible} onClose={onClose}>
|
||||
<Text className="text-lg md:text-xl" numberOfLines={1}>
|
||||
@@ -37,10 +43,13 @@ const FileMenu = () => {
|
||||
<List.Item icon={<Ionicons name="pencil" />}>Rename</List.Item>
|
||||
<List.Item icon={<Ionicons name="copy" />}>Copy</List.Item>
|
||||
<List.Item icon={<Ionicons name="cut-outline" />}>Move</List.Item>
|
||||
<List.Item icon={<Ionicons name="download" />} onPress={onDownload}>
|
||||
Download
|
||||
</List.Item>
|
||||
<List.Item icon={<Ionicons name="trash" />}>Delete</List.Item>
|
||||
</List>
|
||||
|
||||
<HStack className="justify-end mt-6">
|
||||
<HStack className="justify-end mt-6 hidden md:flex">
|
||||
<Button variant="ghost" onPress={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { FileItem } from "@/types/files";
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
type FilesContextType = {
|
||||
files: FileItem[];
|
||||
};
|
||||
|
||||
export const FilesContext = createContext<FilesContextType>({
|
||||
files: [],
|
||||
});
|
||||
|
||||
export const useFilesContext = () => useContext(FilesContext);
|
||||
@@ -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<Audio.Sound | null>(null);
|
||||
const [curFileIdx, setFileIdx] = useState(-1);
|
||||
const [status, setStatus] = useState<AVPlaybackStatusSuccess | null>(null);
|
||||
const [mediaTags, setMediaTags] = useState<MediaTags | null>(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 (
|
||||
<Box className="flex-1 relative">
|
||||
<Box className="absolute -inset-10 -z-[1]">
|
||||
<Image
|
||||
source={mediaTags?.picture ? { uri: mediaTags?.picture } : bgImage}
|
||||
style={cn("absolute -inset-5 w-full h-full")}
|
||||
resizeMode="cover"
|
||||
blurRadius={10}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box className="absolute inset-0 z-[1] bg-black bg-opacity-50 flex flex-col items-center justify-center p-4 md:p-8">
|
||||
{mediaTags?.picture ? (
|
||||
<Image
|
||||
source={{ uri: mediaTags.picture }}
|
||||
style={cn("w-full flex-1 max-h-[256px] mb-8")}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Text className="text-white text-lg sm:text-xl">Now Playing</Text>
|
||||
<Text className="text-white text-xl md:text-3xl mt-4" numberOfLines={1}>
|
||||
{mediaTags?.tags?.title || filename}
|
||||
</Text>
|
||||
{mediaTags?.tags?.artist ? (
|
||||
<Text className="text-white mt-2" numberOfLines={1}>
|
||||
{mediaTags.tags.artist}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<Slider
|
||||
minimumValue={0}
|
||||
maximumValue={100}
|
||||
value={
|
||||
((status?.positionMillis || 0) / (status?.durationMillis || 1)) *
|
||||
100
|
||||
}
|
||||
containerStyle={cn("w-full my-4 md:my-8")}
|
||||
onValueChange={async (value) => {
|
||||
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);
|
||||
}}
|
||||
/>
|
||||
|
||||
<HStack className="gap-4">
|
||||
<Button
|
||||
icon={<Ionicons name="chevron-back" />}
|
||||
iconClassName="text-[32px] md:text-[40px]"
|
||||
className="w-16 h-16 md:w-20 md:h-20 rounded-full"
|
||||
onPress={() => playNext(-1)}
|
||||
/>
|
||||
<Button
|
||||
icon={<Ionicons name={status?.isPlaying ? "pause" : "play"} />}
|
||||
iconClassName="text-[40px] md:text-[48px]"
|
||||
className="w-20 h-20 md:w-24 md:h-24 rounded-full"
|
||||
onPress={() => {
|
||||
if (!soundRef.current) {
|
||||
return;
|
||||
}
|
||||
if (status?.isPlaying) {
|
||||
soundRef.current?.pauseAsync();
|
||||
} else {
|
||||
soundRef.current?.playAsync();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
icon={<Ionicons name="chevron-forward" />}
|
||||
iconClassName="text-[32px] md:text-[40px]"
|
||||
className="w-16 h-16 md:w-20 md:h-20 rounded-full"
|
||||
onPress={() => playNext()}
|
||||
/>
|
||||
</HStack>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default AudioPlayer;
|
||||
@@ -18,11 +18,15 @@ type ListItemProps = {
|
||||
className?: any;
|
||||
children: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
onPress?: () => void;
|
||||
};
|
||||
|
||||
const ListItem = ({ className, icon, children }: ListItemProps) => {
|
||||
const ListItem = ({ className, icon, children, onPress }: ListItemProps) => {
|
||||
return (
|
||||
<Pressable style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}>
|
||||
<Pressable
|
||||
style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
|
||||
onPress={onPress}
|
||||
>
|
||||
<HStack className={cn("py-2 border-b border-gray-200", className)}>
|
||||
{icon ? (
|
||||
<Slot.Text style={cn("text-gray-800 text-xl w-8")}>{icon}</Slot.Text>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { ClassInput, create as createTwrnc } from "twrnc";
|
||||
import base64 from "base-64";
|
||||
import utf8 from "utf8";
|
||||
|
||||
export const tw = createTwrnc(require(`../../tailwind.config.js`));
|
||||
|
||||
@@ -9,3 +11,50 @@ export const cn = (...args: ClassInput[]) => {
|
||||
|
||||
return tw.style(...args);
|
||||
};
|
||||
|
||||
export const base64encode = (str?: string | null) => {
|
||||
return str ? base64.encode(str) : null;
|
||||
};
|
||||
|
||||
export const base64decode = (str?: string | null) => {
|
||||
return str ? base64.decode(str) : null;
|
||||
};
|
||||
|
||||
export const encodeUrl = (str?: string | null) => {
|
||||
return str ? encodeURIComponent(base64encode(utf8.encode(str))) : null;
|
||||
};
|
||||
|
||||
export const decodeUrl = (str?: string | null) => {
|
||||
return str ? decodeURIComponent(utf8.decode(base64decode(str))) : null;
|
||||
};
|
||||
|
||||
export const getFileType = (path?: string | null) => {
|
||||
const ext = path?.split(".").pop();
|
||||
const videoExts = "mp4,mkv,webm,avi,mov";
|
||||
if (videoExts.split(",").includes(ext)) {
|
||||
return "video";
|
||||
}
|
||||
|
||||
const imgExts = "jpeg,jpg,png,gif,webp,avif,svg,bmp,ico,tif,tiff";
|
||||
if (imgExts.split(",").includes(ext)) {
|
||||
return "image";
|
||||
}
|
||||
|
||||
const docExts = "pdf,doc,docx,xls,xlsx,ppt,pptx";
|
||||
if (docExts.split(",").includes(ext)) {
|
||||
return "document";
|
||||
}
|
||||
|
||||
const audioExts = "mp3,ogg,wav,flac,aac,amr";
|
||||
if (audioExts.split(",").includes(ext)) {
|
||||
return "audio";
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const getFilename = (path?: string | null) => {
|
||||
let fname = path.split("/").pop()?.split(".").slice(0, -1).join(".");
|
||||
fname = fname.substring(0, fname.indexOf("?"));
|
||||
return fname;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
export type MediaTags = {
|
||||
type: string;
|
||||
version: string;
|
||||
major: number;
|
||||
revision: number;
|
||||
flags: Flags;
|
||||
size: number;
|
||||
tags: Tags;
|
||||
picture?: string;
|
||||
};
|
||||
|
||||
export type Flags = {
|
||||
unsynchronisation: boolean;
|
||||
extended_header: boolean;
|
||||
experimental_indicator: boolean;
|
||||
footer_present: boolean;
|
||||
};
|
||||
|
||||
export type Tags = {
|
||||
title: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
year: string;
|
||||
comment: Comment;
|
||||
track: string;
|
||||
genre: string;
|
||||
// picture: Picture;
|
||||
TALB: Talb;
|
||||
TPE1: Talb;
|
||||
COMM: Comm;
|
||||
TCON: Talb;
|
||||
TIT2: Talb;
|
||||
TRCK: Talb;
|
||||
TYER: Talb;
|
||||
TXXX: Txxx;
|
||||
APIC: APIC;
|
||||
};
|
||||
|
||||
export type APIC = {
|
||||
id: string;
|
||||
size: number;
|
||||
description: string;
|
||||
data: Picture;
|
||||
};
|
||||
|
||||
export type Picture = {
|
||||
format: string;
|
||||
type: string;
|
||||
description: string;
|
||||
data: number[];
|
||||
};
|
||||
|
||||
export type Comm = {
|
||||
id: string;
|
||||
size: number;
|
||||
description: string;
|
||||
data: Comment;
|
||||
};
|
||||
|
||||
export type Comment = {
|
||||
language: string;
|
||||
short_description: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type Talb = {
|
||||
id: string;
|
||||
size: number;
|
||||
description: string;
|
||||
data: string;
|
||||
};
|
||||
|
||||
export type Txxx = {
|
||||
id: string;
|
||||
size: number;
|
||||
description: string;
|
||||
data: Data;
|
||||
};
|
||||
|
||||
export type Data = {
|
||||
user_description: string;
|
||||
data: string;
|
||||
};
|
||||
Reference in New Issue
Block a user