mirror of
https://github.com/khairul169/home-lab.git
synced 2025-04-28 16:49:36 +07:00
fix: audio playback fix, add playlist view
This commit is contained in:
parent
5925f08a6e
commit
e93b33d3d8
File diff suppressed because one or more lines are too long
@ -32,6 +32,6 @@
|
|||||||
</noscript>
|
</noscript>
|
||||||
<!-- The root element for your Expo app. -->
|
<!-- The root element for your Expo app. -->
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script src="/_expo/static/js/web/entry-d757cfde51c8abc54961c1bb601f2b48.js" defer></script>
|
<script src="/_expo/static/js/web/entry-120b4de7dbb4d4577d15df21ce04e72d.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
@ -5,7 +5,7 @@ import { useAuth } from "@/stores/authStore";
|
|||||||
import BackButton from "@ui/BackButton";
|
import BackButton from "@ui/BackButton";
|
||||||
import Input from "@ui/Input";
|
import Input from "@ui/Input";
|
||||||
import { Stack, router, useLocalSearchParams } from "expo-router";
|
import { Stack, router, useLocalSearchParams } from "expo-router";
|
||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
import { useMutation, useQuery } from "react-query";
|
import { useMutation, useQuery } from "react-query";
|
||||||
import FileDrop from "@/components/pages/files/FileDrop";
|
import FileDrop from "@/components/pages/files/FileDrop";
|
||||||
import { showToast } from "@/stores/toastStore";
|
import { showToast } from "@/stores/toastStore";
|
||||||
@ -15,12 +15,14 @@ import { Ionicons } from "@ui/Icons";
|
|||||||
import FileInlineViewer from "@/components/pages/files/FileInlineViewer";
|
import FileInlineViewer from "@/components/pages/files/FileInlineViewer";
|
||||||
import { decodeUrl, encodeUrl } from "@/lib/utils";
|
import { decodeUrl, encodeUrl } from "@/lib/utils";
|
||||||
import { FilesContext } from "@/components/pages/files/FilesContext";
|
import { FilesContext } from "@/components/pages/files/FilesContext";
|
||||||
|
import { FileItem } from "@/types/files";
|
||||||
|
|
||||||
const FilesPage = () => {
|
const FilesPage = () => {
|
||||||
const { isLoggedIn } = useAuth();
|
const { isLoggedIn } = useAuth();
|
||||||
const [params, setParams] = useAsyncStorage("files", {
|
const [params, setParams] = useAsyncStorage("files", {
|
||||||
path: "",
|
path: "",
|
||||||
});
|
});
|
||||||
|
const [viewFile, setViewFile] = useState<FileItem | null>(null);
|
||||||
const searchParams = useLocalSearchParams();
|
const searchParams = useLocalSearchParams();
|
||||||
const parentPath =
|
const parentPath =
|
||||||
params.path.length > 0
|
params.path.length > 0
|
||||||
@ -64,7 +66,7 @@ const FilesPage = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FilesContext.Provider value={{ files: data }}>
|
<FilesContext.Provider value={{ files: data, viewFile, setViewFile }}>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
options={{ headerLeft: () => <BackButton />, title: "Files" }}
|
options={{ headerLeft: () => <BackButton />, title: "Files" }}
|
||||||
/>
|
/>
|
||||||
@ -98,15 +100,16 @@ const FilesPage = () => {
|
|||||||
files={data}
|
files={data}
|
||||||
onSelect={(file) => {
|
onSelect={(file) => {
|
||||||
if (file.isDirectory) {
|
if (file.isDirectory) {
|
||||||
return setParams({ ...params, path: file.path });
|
setParams({ ...params, path: file.path });
|
||||||
|
} else {
|
||||||
|
setViewFile(file);
|
||||||
}
|
}
|
||||||
router.push("/apps/files?view=" + encodeUrl(file.path));
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</FileDrop>
|
</FileDrop>
|
||||||
|
|
||||||
<FileInlineViewer
|
<FileInlineViewer
|
||||||
path={decodeUrl(searchParams.view as string)}
|
file={viewFile}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
if (router.canGoBack()) {
|
if (router.canGoBack()) {
|
||||||
router.back();
|
router.back();
|
||||||
|
@ -11,14 +11,17 @@ import { HStack } from "@ui/Stack";
|
|||||||
import Box from "@ui/Box";
|
import Box from "@ui/Box";
|
||||||
import Apps from "../components/pages/home/Apps";
|
import Apps from "../components/pages/home/Apps";
|
||||||
import { Stack } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
|
import { useIsFocused } from "@/hooks/useIsFocused";
|
||||||
|
|
||||||
const HomePage = () => {
|
const HomePage = () => {
|
||||||
const { isLoggedIn } = useAuth();
|
const { isLoggedIn } = useAuth();
|
||||||
|
const isFocused = useIsFocused();
|
||||||
|
|
||||||
const { data: system } = useQuery({
|
const { data: system } = useQuery({
|
||||||
queryKey: ["system"],
|
queryKey: ["system"],
|
||||||
queryFn: () => api.system.$get().then((r) => r.json()),
|
queryFn: () => api.system.$get().then((r) => r.json()),
|
||||||
refetchInterval: 1000,
|
refetchInterval: 1000,
|
||||||
enabled: isLoggedIn,
|
enabled: isLoggedIn && isFocused,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!isLoggedIn) {
|
if (!isLoggedIn) {
|
||||||
|
293
src/components/containers/AudioPlayer.tsx
Normal file
293
src/components/containers/AudioPlayer.tsx
Normal file
@ -0,0 +1,293 @@
|
|||||||
|
import { AVPlaybackStatusSuccess, Audio } from "expo-av";
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import jsmediatags from "jsmediatags/build2/jsmediatags";
|
||||||
|
import { MediaTags } from "@/types/mediaTags";
|
||||||
|
import Box from "../ui/Box";
|
||||||
|
import Text from "../ui/Text";
|
||||||
|
import {
|
||||||
|
base64encode,
|
||||||
|
cn,
|
||||||
|
encodeUrl,
|
||||||
|
getFileType,
|
||||||
|
getFilename,
|
||||||
|
} from "@/lib/utils";
|
||||||
|
import { FlatList, Image, Pressable } from "react-native";
|
||||||
|
import { useFilesContext } from "../pages/files/FilesContext";
|
||||||
|
import { HStack } from "../ui/Stack";
|
||||||
|
import Button from "../ui/Button";
|
||||||
|
import { Ionicons } from "../ui/Icons";
|
||||||
|
import { Slider } from "@miblanchard/react-native-slider";
|
||||||
|
import bgImage from "@/assets/images/audioplayer-bg.jpeg";
|
||||||
|
import { FileItem } from "@/types/files";
|
||||||
|
import Input from "@ui/Input";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
path: string;
|
||||||
|
uri: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AudioPlayer = ({ path, uri }: Props) => {
|
||||||
|
const { files, setViewFile } = 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 playlist = useMemo(() => {
|
||||||
|
return files.filter((f) => getFileType(f.name) === "audio");
|
||||||
|
}, [files]);
|
||||||
|
|
||||||
|
const playIdx = (idx: number) => {
|
||||||
|
if (!playlist.length || idx < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = playlist[idx % playlist.length];
|
||||||
|
if (file) {
|
||||||
|
setViewFile(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const playNext = (increment = 1) => {
|
||||||
|
if (!playlist.length || curFileIdx < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
playIdx(curFileIdx + increment);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!playlist?.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileIdx = playlist.findIndex((file) => path === file.path);
|
||||||
|
setFileIdx(fileIdx);
|
||||||
|
|
||||||
|
const onNext = () => playIdx(fileIdx + 1);
|
||||||
|
|
||||||
|
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) {
|
||||||
|
onNext();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await sound.playAsync();
|
||||||
|
|
||||||
|
if (soundRef.current !== sound) {
|
||||||
|
await sound.unloadAsync();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof DOMException) {
|
||||||
|
if (err.name === "NotSupportedError") {
|
||||||
|
setTimeout(onNext, 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// function loadMediaTags() {
|
||||||
|
// 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);
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
play();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
soundRef.current?.unloadAsync();
|
||||||
|
soundRef.current = null;
|
||||||
|
};
|
||||||
|
}, [uri, path, playlist]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HStack className="flex-1 items-stretch">
|
||||||
|
<Box className="flex-1 relative overflow-hidden">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<Playlist playlist={playlist} currentIdx={curFileIdx} playIdx={playIdx} />
|
||||||
|
</HStack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type PlaylistProps = {
|
||||||
|
playlist: FileItem[];
|
||||||
|
currentIdx: number;
|
||||||
|
playIdx: (idx: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Playlist = ({ playlist, currentIdx, playIdx }: PlaylistProps) => {
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
|
const list = useMemo(() => {
|
||||||
|
if (!search?.length) {
|
||||||
|
return playlist;
|
||||||
|
}
|
||||||
|
return playlist.filter((i) =>
|
||||||
|
i.name.toLowerCase().includes(search.toLowerCase())
|
||||||
|
);
|
||||||
|
}, [search, playlist]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className="hidden md:flex w-1/3 max-w-[400px] bg-black">
|
||||||
|
<HStack className="pl-6 pr-2 items-center">
|
||||||
|
<Text className="text-2xl text-white py-4 flex-1" numberOfLines={1}>
|
||||||
|
Playlist
|
||||||
|
</Text>
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChangeText={setSearch}
|
||||||
|
placeholder="Search"
|
||||||
|
placeholderTextColor="white"
|
||||||
|
inputClassName="text-white"
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<FlatList
|
||||||
|
data={list}
|
||||||
|
keyExtractor={(i) => i.path}
|
||||||
|
renderItem={({ item, index }) => (
|
||||||
|
<PlaylistItem
|
||||||
|
file={item}
|
||||||
|
isCurrent={index === currentIdx}
|
||||||
|
onPress={() => playIdx(index)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type PlaylistItemProps = {
|
||||||
|
file: FileItem;
|
||||||
|
isCurrent: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PlaylistItem = ({ file, isCurrent, onPress }: PlaylistItemProps) => {
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
style={cn(
|
||||||
|
"py-4 px-5 border-b border-gray-800",
|
||||||
|
isCurrent && "bg-[#323232]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Text className="text-white" numberOfLines={1}>
|
||||||
|
{file.name}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AudioPlayer;
|
@ -9,17 +9,18 @@ import Modal from "react-native-modal";
|
|||||||
import { Video, ResizeMode } from "expo-av";
|
import { Video, ResizeMode } from "expo-av";
|
||||||
import { getFileUrl, openFile } from "@/app/apps/files/utils";
|
import { getFileUrl, openFile } from "@/app/apps/files/utils";
|
||||||
import { Image } from "react-native";
|
import { Image } from "react-native";
|
||||||
import AudioPlayer from "@ui/AudioPlayer";
|
import AudioPlayer from "@/components/containers/AudioPlayer";
|
||||||
|
import { FileItem } from "@/types/files";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
path?: string | null;
|
file?: FileItem | null;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const FileViewer = ({ path }: Pick<Props, "path">) => {
|
const FileViewer = ({ file }: Pick<Props, "file">) => {
|
||||||
const videoPlayerRef = useRef<Video>(null!);
|
const videoPlayerRef = useRef<Video>(null!);
|
||||||
const fileType = getFileType(path);
|
const fileType = getFileType(file.path);
|
||||||
const uri = getFileUrl(path);
|
const uri = getFileUrl(file.path);
|
||||||
|
|
||||||
if (fileType === "video") {
|
if (fileType === "video") {
|
||||||
return (
|
return (
|
||||||
@ -36,7 +37,7 @@ const FileViewer = ({ path }: Pick<Props, "path">) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (fileType === "audio") {
|
if (fileType === "audio") {
|
||||||
return <AudioPlayer path={path} uri={uri} />;
|
return <AudioPlayer path={file.path} uri={uri} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fileType === "image") {
|
if (fileType === "image") {
|
||||||
@ -51,18 +52,22 @@ const FileViewer = ({ path }: Pick<Props, "path">) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className="w-full flex-1 flex flex-col items-center justify-center">
|
<Box className="w-full flex-1 flex flex-col items-center justify-center">
|
||||||
<Button onPress={() => openFile(path)}>Open File</Button>
|
<Button onPress={() => openFile(file.path)}>Open File</Button>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const FileInlineViewer = ({ path, onClose }: Props) => {
|
const FileInlineViewer = ({ file, onClose }: Props) => {
|
||||||
const filename = path?.split("/").pop();
|
const filename = file?.path?.split("/").pop();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isVisible={!!path} onBackButtonPress={onClose} style={cn("m-0")}>
|
<Modal
|
||||||
|
isVisible={!!file?.path}
|
||||||
|
onBackButtonPress={onClose}
|
||||||
|
style={cn("m-0")}
|
||||||
|
>
|
||||||
<Box className="flex-1 w-full bg-gray-950">
|
<Box className="flex-1 w-full bg-gray-950">
|
||||||
<HStack className="gap-2 p-2 bg-gray-900 relative z-10">
|
<HStack className="gap-2 p-2 bg-black border-b border-gray-800 relative z-10">
|
||||||
<Button
|
<Button
|
||||||
icon={<Ionicons name="arrow-back" />}
|
icon={<Ionicons name="arrow-back" />}
|
||||||
iconClassName="text-white"
|
iconClassName="text-white"
|
||||||
@ -77,18 +82,18 @@ const FileInlineViewer = ({ path, onClose }: Props) => {
|
|||||||
icon={<Ionicons name="download-outline" />}
|
icon={<Ionicons name="download-outline" />}
|
||||||
iconClassName="text-white text-xl"
|
iconClassName="text-white text-xl"
|
||||||
className="px-3"
|
className="px-3"
|
||||||
onPress={() => openFile(path, true)}
|
onPress={() => openFile(file?.path, true)}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
icon={<Ionicons name="open-outline" />}
|
icon={<Ionicons name="open-outline" />}
|
||||||
iconClassName="text-white text-xl"
|
iconClassName="text-white text-xl"
|
||||||
className="px-3"
|
className="px-3"
|
||||||
onPress={() => openFile(path)}
|
onPress={() => openFile(file?.path)}
|
||||||
/>
|
/>
|
||||||
</HStack>
|
</HStack>
|
||||||
|
|
||||||
<FileViewer path={path} />
|
{file ? <FileViewer file={file} /> : null}
|
||||||
</Box>
|
</Box>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
@ -3,10 +3,14 @@ import { createContext, useContext } from "react";
|
|||||||
|
|
||||||
type FilesContextType = {
|
type FilesContextType = {
|
||||||
files: FileItem[];
|
files: FileItem[];
|
||||||
|
viewFile: FileItem | null;
|
||||||
|
setViewFile: (file: FileItem | null) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const FilesContext = createContext<FilesContextType>({
|
export const FilesContext = createContext<FilesContextType>({
|
||||||
files: [],
|
files: [],
|
||||||
|
viewFile: null,
|
||||||
|
setViewFile: () => null,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const useFilesContext = () => useContext(FilesContext);
|
export const useFilesContext = () => useContext(FilesContext);
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { useIsFocused } from "@/hooks/useIsFocused";
|
||||||
import api from "@/lib/api";
|
import api from "@/lib/api";
|
||||||
import Box from "@ui/Box";
|
import Box from "@ui/Box";
|
||||||
import Button from "@ui/Button";
|
import Button from "@ui/Button";
|
||||||
@ -8,6 +9,7 @@ import { useQuery } from "react-query";
|
|||||||
|
|
||||||
const ProcessList = () => {
|
const ProcessList = () => {
|
||||||
const [sort, setSort] = useState<string>("mem");
|
const [sort, setSort] = useState<string>("mem");
|
||||||
|
const isFocused = useIsFocused();
|
||||||
|
|
||||||
const { data } = useQuery({
|
const { data } = useQuery({
|
||||||
queryKey: ["process", sort],
|
queryKey: ["process", sort],
|
||||||
@ -18,6 +20,7 @@ const ProcessList = () => {
|
|||||||
},
|
},
|
||||||
select: (i) => i.list,
|
select: (i) => i.list,
|
||||||
refetchInterval: 1000,
|
refetchInterval: 1000,
|
||||||
|
enabled: isFocused,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -1,196 +0,0 @@
|
|||||||
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;
|
|
15
src/hooks/useIsFocused.ts
Normal file
15
src/hooks/useIsFocused.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { useFocusEffect } from "expo-router";
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
|
||||||
|
export const useIsFocused = () => {
|
||||||
|
const [isFocused, setFocused] = useState(false);
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
setFocused(true);
|
||||||
|
return () => setFocused(false);
|
||||||
|
}, [])
|
||||||
|
);
|
||||||
|
|
||||||
|
return isFocused;
|
||||||
|
};
|
Loading…
x
Reference in New Issue
Block a user