feat: add inline file viewer, add audio player

This commit is contained in:
2024-03-17 07:03:24 +07:00
parent a371a9e569
commit 7ca128a02c
16 changed files with 565 additions and 41 deletions
+30 -5
View File
@@ -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>
);
};
+5 -4
View File
@@ -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;
+1 -1
View File
@@ -87,4 +87,4 @@ const FileItemList = ({
);
};
export default FileList;
export default React.memo(FileList);
+10 -1
View File
@@ -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);
+196
View File
@@ -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;
+6 -2
View File
@@ -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>
+49
View File
@@ -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;
};
+83
View File
@@ -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;
};