fix: audio playback fix, add playlist view

This commit is contained in:
Khairul Hidayat 2024-03-17 21:35:31 +07:00
parent 5925f08a6e
commit e93b33d3d8
10 changed files with 398 additions and 297 deletions

View File

@ -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>

View File

@ -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();

View File

@ -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) {

View 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;

View File

@ -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>
); );

View File

@ -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);

View File

@ -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 (

View File

@ -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
View 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;
};