feat: new music player ui

This commit is contained in:
Khairul Hidayat 2024-03-24 02:21:50 +07:00
parent ba474ceffe
commit fae66903a0
11 changed files with 567 additions and 391 deletions

View File

@ -1,5 +1,5 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { Slot, Stack, router, usePathname } from "expo-router"; import { Stack, router, usePathname } from "expo-router";
import { QueryClientProvider } from "react-query"; import { QueryClientProvider } from "react-query";
import queryClient from "@/lib/queryClient"; import queryClient from "@/lib/queryClient";
import { View } from "react-native"; import { View } from "react-native";
@ -12,6 +12,9 @@ import { useStore } from "zustand";
import authStore from "@/stores/authStore"; import authStore from "@/stores/authStore";
import { toastStore } from "@/stores/toastStore"; import { toastStore } from "@/stores/toastStore";
import Dialog from "@ui/Dialog"; import Dialog from "@ui/Dialog";
import AudioPlayerProvider from "@/components/containers/AudioPlayerProvider";
import AudioPlayer from "@/components/pages/music/AudioPlayer";
import MiniPlayer from "@/components/pages/music/MiniPlayer";
const RootLayout = () => { const RootLayout = () => {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
@ -40,13 +43,15 @@ const RootLayout = () => {
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<StatusBar style="auto" /> <StatusBar style="auto" />
<View <View
style={cn("flex-1 bg-[#f2f7fb] overflow-hidden", { style={cn("flex-1 bg-[#f2f7fb] overflow-hidden relative", {
paddingTop: insets.top, paddingTop: insets.top,
})} })}
> >
<Stack <Stack
screenOptions={{ contentStyle: { backgroundColor: "#f2f7fb" } }} screenOptions={{ contentStyle: { backgroundColor: "#f2f7fb" } }}
/> />
<MiniPlayer />
<AudioPlayer />
</View> </View>
<Toast <Toast
ref={(ref) => { ref={(ref) => {
@ -54,6 +59,7 @@ const RootLayout = () => {
}} }}
/> />
<Dialog /> <Dialog />
<AudioPlayerProvider />
</QueryClientProvider> </QueryClientProvider>
); );
}; };

View File

@ -19,6 +19,8 @@ import Head from "@/components/utility/Head";
import Container from "@ui/Container"; import Container from "@ui/Container";
import FileUpload from "@/components/pages/files/FileUpload"; import FileUpload from "@/components/pages/files/FileUpload";
import ActionButton from "@/components/pages/files/ActionButton"; import ActionButton from "@/components/pages/files/ActionButton";
import { getFileType } from "@/lib/utils";
import { audioPlayer } from "@/stores/audioPlayerStore";
const FilesPage = () => { const FilesPage = () => {
const { isLoggedIn } = useAuth(); const { isLoggedIn } = useAuth();
@ -105,12 +107,17 @@ const FilesPage = () => {
<FileDrop onFileDrop={onFileDrop} isDisabled={upload.isLoading}> <FileDrop onFileDrop={onFileDrop} isDisabled={upload.isLoading}>
<FileList <FileList
files={data} files={data}
onSelect={(file) => { onSelect={(file, idx) => {
if (file.isDirectory) { if (file.isDirectory) {
setParams({ path: file.path }); return setParams({ path: file.path });
} else {
setViewFile(file);
} }
const fileType = getFileType(file.path);
if (fileType === "audio") {
return audioPlayer.play(data, idx);
}
setViewFile(file);
}} }}
/> />
</FileDrop> </FileDrop>

View File

@ -1,374 +0,0 @@
import { AVPlaybackStatusSuccess, Audio } from "expo-av";
import { forwardRef, 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";
import { useAsyncStorage } from "@/hooks/useAsyncStorage";
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 [playback, setPlayback] = useAsyncStorage("playback", {
repeat: false,
shuffle: false,
});
const filename = getFilename(decodeURIComponent(uri));
const playlist = useMemo(() => {
let items = files.filter((f) => getFileType(f.name) === "audio");
if (playback.shuffle) {
items = items.sort(() => Math.random() - 0.5);
}
return items;
}, [files, playback.shuffle]);
const playIdx = (idx: number) => {
if (!playlist.length || idx < 0) {
return;
}
const file = playlist[idx % playlist.length];
if (file) {
setViewFile(file);
}
};
const playNext = (increment = 1, startIdx?: number) => {
const curIdx = startIdx ?? curFileIdx;
if (!playlist.length || curIdx < 0) {
return;
}
playIdx(curIdx + increment);
};
useEffect(() => {
if (!playlist?.length) {
return;
}
const fileIdx = playlist.findIndex((file) => path === file.path);
setFileIdx(fileIdx);
const onNext = () => playNext(1, fileIdx);
async function play() {
try {
const { sound } = await Audio.Sound.createAsync({ uri });
soundRef.current = sound;
sound.setIsLoopingAsync(playback.repeat);
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);
},
});
}
loadMediaTags();
play();
return () => {
soundRef.current?.unloadAsync();
soundRef.current = null;
};
}, [uri, path, playlist]);
useEffect(() => {
if (status?.isLoaded) {
soundRef.current?.setIsLoopingAsync(playback.repeat);
}
}, [playback.repeat, status?.isLoaded]);
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}
<Box className="w-full max-w-3xl mx-auto my-4 md:my-8">
<Slider
minimumValue={0}
maximumValue={100}
value={
((status?.positionMillis || 0) /
(status?.durationMillis || 1)) *
100
}
thumbStyle={cn("bg-blue-500")}
trackStyle={cn("bg-white/30 rounded-full h-2")}
minimumTrackTintColor="#6366F1"
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="justify-between">
<Text className="text-white">
{formatTime(status?.positionMillis || 0)}
</Text>
<Text className="text-white">
{formatTime(status?.durationMillis || 0)}
</Text>
</HStack>
</Box>
<HStack className="gap-4">
<Button
icon={<Ionicons name="repeat" />}
iconClassName={`text-[24px] md:text-[32px] text-white ${
playback.repeat ? "opacity-100" : "opacity-50"
}`}
variant="ghost"
className="w-16 h-16 md:w-20 md:h-20 rounded-full"
onPress={() => setPlayback({ repeat: !playback.repeat })}
/>
<Button
icon={<Ionicons name="play-back" />}
iconClassName="text-[24px] md:text-[32px] text-white"
variant="ghost"
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-[32px] md:text-[36px]"
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="play-forward" />}
iconClassName="text-[24px] md:text-[32px] text-white"
variant="ghost"
className="w-16 h-16 md:w-20 md:h-20 rounded-full"
onPress={() => playNext()}
/>
<Button
icon={<Ionicons name="shuffle" />}
iconClassName={`text-[24px] md:text-[32px] text-white ${
playback.shuffle ? "opacity-100" : "opacity-50"
}`}
variant="ghost"
className="w-16 h-16 md:w-20 md:h-20 rounded-full"
onPress={() => setPlayback({ shuffle: !playback.shuffle })}
/>
</HStack>
</Box>
</Box>
<Playlist playlist={playlist} currentIdx={curFileIdx} playIdx={playIdx} />
</HStack>
);
};
type PlaylistProps = {
playlist: FileItem[];
currentIdx: number;
playIdx: (idx: number) => void;
};
const PLAYLIST_ITEM_HEIGHT = 49;
const Playlist = ({ playlist, currentIdx, playIdx }: PlaylistProps) => {
const containerRef = useRef<any>();
const [search, setSearch] = useState("");
useEffect(() => {
if (currentIdx >= 0) {
containerRef.current?.scrollToIndex({
index: currentIdx,
animated: true,
});
}
}, [currentIdx]);
const list = useMemo(() => {
const items = playlist.map((i, idx) => ({ ...i, idx }));
if (!search?.length) {
return items;
}
return items.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
ref={containerRef}
data={list}
keyExtractor={(i) => i.path}
renderItem={({ item }) => (
<PlaylistItem
file={item}
isCurrent={item.idx === currentIdx}
onPress={() => playIdx(item.idx)}
/>
)}
onScrollToIndexFailed={({ index }) => {
containerRef.current?.scrollToOffset({
offset: (index ?? 0) * PLAYLIST_ITEM_HEIGHT,
animated: true,
});
}}
/>
</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>
);
};
function formatTime(time: number) {
const minutes = Math.floor(time / 60 / 1000);
const seconds = Math.floor((time / 1000) % 60);
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
}
export default AudioPlayer;

View File

@ -0,0 +1,94 @@
import { getFileUrl } from "@/app/apps/lib";
import { base64encode } from "@/lib/utils";
import { audioPlayer, audioPlayerStore } from "@/stores/audioPlayerStore";
import { AVPlaybackStatusSuccess, Audio } from "expo-av";
import jsmediatags from "jsmediatags/build2/jsmediatags";
import { useEffect, useRef } from "react";
import { useStore } from "zustand";
const AudioPlayerProvider = () => {
const { currentIdx, playlist, repeat, status } = useStore(audioPlayerStore);
const soundRef = useRef<Audio.Sound | null>(null);
useEffect(() => {
if (!playlist?.length || currentIdx < 0) {
return;
}
const fileData = playlist[currentIdx];
const uri = getFileUrl(fileData.path);
async function play() {
try {
const { sound } = await Audio.Sound.createAsync({ uri });
soundRef.current = sound;
audioPlayerStore.setState({ sound });
sound.setIsLoopingAsync(repeat);
sound.setOnPlaybackStatusUpdate((st: AVPlaybackStatusSuccess) => {
audioPlayerStore.setState({ status: st as any });
if (st.didJustFinish) {
audioPlayer.next();
}
});
await sound.playAsync();
if (soundRef.current !== sound) {
await sound.unloadAsync();
}
} catch (err) {
if (err instanceof DOMException) {
if (err.name === "NotSupportedError") {
setTimeout(audioPlayer.next, 1000);
}
}
}
}
function loadMediaTags() {
const tagsReader = new jsmediatags.Reader(uri + "&dl=true");
audioPlayerStore.setState({ mediaTags: 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;
}
audioPlayerStore.setState({ mediaTags: mediaTagsResult });
},
});
}
loadMediaTags();
play();
return () => {
soundRef.current?.unloadAsync();
soundRef.current = null;
};
}, [currentIdx, playlist]);
useEffect(() => {
if (status?.isLoaded) {
soundRef.current?.setIsLoopingAsync(repeat);
}
}, [repeat, status?.isLoaded]);
return null;
};
export default AudioPlayerProvider;

View File

@ -9,7 +9,6 @@ import Modal from "react-native-modal";
import { Video, ResizeMode } from "expo-av"; import { Video, ResizeMode } from "expo-av";
import { getFileUrl, openFile } from "@/app/apps/lib"; import { getFileUrl, openFile } from "@/app/apps/lib";
import { Image } from "react-native"; import { Image } from "react-native";
import AudioPlayer from "@/components/containers/AudioPlayer";
import { FileItem } from "@/types/files"; import { FileItem } from "@/types/files";
type Props = { type Props = {
@ -36,10 +35,6 @@ const FileViewer = ({ file }: Pick<Props, "file">) => {
); );
} }
if (fileType === "audio") {
return <AudioPlayer path={file.path} uri={uri} />;
}
if (fileType === "image") { if (fileType === "image") {
return ( return (
<Image <Image

View File

@ -11,9 +11,9 @@ import FileMenu, { openFileMenu } from "./FileMenu";
type FileListProps = { type FileListProps = {
files?: FileItem[]; files?: FileItem[];
onSelect?: (file: FileItem) => void; onSelect?: (file: FileItem, idx: number) => void;
// onMenu?: (file: FileItem) => void; // onMenu?: (file: FileItem) => void;
onLongPress?: (file: FileItem) => void; onLongPress?: (file: FileItem, idx: number) => void;
}; };
const FileList = ({ files, onSelect, onLongPress }: FileListProps) => { const FileList = ({ files, onSelect, onLongPress }: FileListProps) => {
@ -23,11 +23,11 @@ const FileList = ({ files, onSelect, onLongPress }: FileListProps) => {
style={cn("flex-1")} style={cn("flex-1")}
contentContainerStyle={cn("bg-white")} contentContainerStyle={cn("bg-white")}
data={files || []} data={files || []}
renderItem={({ item }) => ( renderItem={({ item, index }) => (
<FileItemList <FileItemList
file={item} file={item}
onPress={() => onSelect?.(item)} onPress={() => onSelect?.(item, index)}
onLongPress={() => onLongPress?.(item)} onLongPress={() => onLongPress?.(item, index)}
onMenuPress={() => openFileMenu(item)} onMenuPress={() => openFileMenu(item)}
/> />
)} )}

View File

@ -7,6 +7,7 @@ import Button from "@ui/Button";
import { useNavigation } from "expo-router"; import { useNavigation } from "expo-router";
import { showDialog } from "@/stores/dialogStore"; import { showDialog } from "@/stores/dialogStore";
import { wakePcUp } from "@/app/apps/lib"; import { wakePcUp } from "@/app/apps/lib";
import { audioPlayerStore } from "@/stores/audioPlayerStore";
type Props = ComponentProps<typeof Box>; type Props = ComponentProps<typeof Box>;
@ -19,6 +20,11 @@ const Apps = (props: Props) => {
icon: <Ionicons name="folder" />, icon: <Ionicons name="folder" />,
path: "files", path: "files",
}, },
{
name: "Music",
icon: <Ionicons name="musical-notes" />,
action: () => audioPlayerStore.setState({ expanded: true }),
},
{ {
name: "Terminal", name: "Terminal",
icon: <Ionicons name="terminal" />, icon: <Ionicons name="terminal" />,

View File

@ -0,0 +1,252 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import Box from "../../ui/Box";
import Text from "../../ui/Text";
import { cn, getFilename } from "@/lib/utils";
import { FlatList, Image, Pressable } from "react-native";
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";
import { useStore } from "zustand";
import { audioPlayer, audioPlayerStore } from "@/stores/audioPlayerStore";
import Modal from "react-native-modal";
const AudioPlayer = () => {
const expanded = useStore(audioPlayerStore, (i) => i.expanded);
const onClose = () => audioPlayerStore.setState({ expanded: false });
return (
<Modal
isVisible={expanded}
onBackdropPress={onClose}
onBackButtonPress={onClose}
style={cn("m-0")}
>
<AudioPlayerView onClose={onClose} />
</Modal>
);
};
const AudioPlayerView = React.memo(({ onClose }: { onClose: () => void }) => {
const { playlist, currentIdx, status, repeat, shuffle, mediaTags } =
useStore(audioPlayerStore);
const current = playlist[currentIdx];
const filename = getFilename(current?.path);
return (
<Box className="flex-1 items-stretch 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>
<HStack className="absolute inset-0 z-[1] bg-black bg-opacity-50 items-stretch">
<Button
icon={<Ionicons name="arrow-back" />}
iconClassName="text-white text-2xl"
variant="ghost"
className="absolute top-4 left-0 z-10"
size="lg"
onPress={onClose}
/>
<Box className="flex flex-col items-center justify-center p-8 flex-1 overflow-hidden">
{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}
<Box className="w-full max-w-3xl mx-auto my-4 md:my-8">
<Slider
minimumValue={0}
maximumValue={100}
value={
((status?.positionMillis || 0) /
(status?.durationMillis || 1)) *
100
}
thumbStyle={cn("bg-blue-500")}
trackStyle={cn("bg-white/30 rounded-full h-2")}
minimumTrackTintColor="#6366F1"
onValueChange={(value) => {
const [progress] = value;
audioPlayer.seek(progress);
}}
/>
<HStack className="justify-between">
<Text className="text-white">
{formatTime(status?.positionMillis || 0)}
</Text>
<Text className="text-white">
{formatTime(status?.durationMillis || 0)}
</Text>
</HStack>
</Box>
<HStack className="md:gap-4">
<Button
icon={<Ionicons name="repeat" />}
iconClassName={`text-[24px] md:text-[32px] text-white ${
repeat ? "opacity-100" : "opacity-50"
}`}
variant="ghost"
className="w-16 h-16 md:w-20 md:h-20 rounded-full"
onPress={() => audioPlayerStore.setState({ repeat: !repeat })}
/>
<Button
icon={<Ionicons name="play-back" />}
iconClassName="text-[24px] md:text-[32px] text-white"
variant="ghost"
className="w-16 h-16 md:w-20 md:h-20 rounded-full"
onPress={audioPlayer.prev}
/>
<Button
icon={<Ionicons name={status?.isPlaying ? "pause" : "play"} />}
iconClassName="text-[32px] md:text-[36px]"
className="w-20 h-20 md:w-24 md:h-24 rounded-full"
onPress={audioPlayer.togglePlay}
/>
<Button
icon={<Ionicons name="play-forward" />}
iconClassName="text-[24px] md:text-[32px] text-white"
variant="ghost"
className="w-16 h-16 md:w-20 md:h-20 rounded-full"
onPress={audioPlayer.next}
/>
<Button
icon={<Ionicons name="shuffle" />}
iconClassName={`text-[24px] md:text-[32px] text-white ${
shuffle ? "opacity-100" : "opacity-50"
}`}
variant="ghost"
className="w-16 h-16 md:w-20 md:h-20 rounded-full"
onPress={() => audioPlayerStore.setState({ shuffle: !shuffle })}
/>
</HStack>
</Box>
<Playlist />
</HStack>
</Box>
);
});
const PLAYLIST_ITEM_HEIGHT = 49;
const Playlist = () => {
const playlist = useStore(audioPlayerStore, (i) => i.playlist);
const currentIdx = useStore(audioPlayerStore, (i) => i.currentIdx);
const containerRef = useRef<any>();
const [search, setSearch] = useState("");
useEffect(() => {
if (currentIdx >= 0) {
containerRef.current?.scrollToOffset({
offset: currentIdx * PLAYLIST_ITEM_HEIGHT,
animated: true,
});
}
}, [currentIdx]);
const list = useMemo(() => {
const items = playlist.map((i, idx) => ({ ...i, idx }));
if (!search?.length) {
return items;
}
return items.filter((i) =>
i.name.toLowerCase().includes(search.toLowerCase())
);
}, [search, playlist]);
return (
<Box className="hidden md:flex w-1/3 max-w-[400px] p-4">
<Box className="bg-black/30 rounded-xl flex-1 border border-white/20 overflow-hidden">
<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
ref={containerRef}
data={list}
showsVerticalScrollIndicator={false}
showsHorizontalScrollIndicator={false}
keyExtractor={(i) => i.path}
renderItem={({ item }) => (
<PlaylistItem
file={item}
isCurrent={item.idx === currentIdx}
onPress={() =>
audioPlayerStore.setState({ currentIdx: item.idx })
}
/>
)}
/>
</Box>
</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-white/10",
isCurrent && "bg-[#323232]"
)}
>
<Text className="text-white" numberOfLines={1}>
{file.name}
</Text>
</Pressable>
);
};
function formatTime(time: number) {
const minutes = Math.floor(time / 60 / 1000);
const seconds = Math.floor((time / 1000) % 60);
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
}
export default React.memo(AudioPlayer);

View File

@ -0,0 +1,73 @@
import { cn, getFilename } from "@/lib/utils";
import { audioPlayer, audioPlayerStore } from "@/stores/audioPlayerStore";
import { Slider } from "@miblanchard/react-native-slider";
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 from "react";
import { Pressable } from "react-native";
import { useStore } from "zustand";
const MiniPlayer = () => {
const { status, playlist, currentIdx, mediaTags } =
useStore(audioPlayerStore);
const current = playlist[currentIdx];
const filename = getFilename(current?.path);
if (!status?.isLoaded) {
return null;
}
return (
<>
<Box className="w-full h-20 flex md:hidden" />
<HStack className="absolute bottom-0 right-0 md:bottom-4 md:right-4 bg-white md:rounded-lg shadow-lg w-full max-w-sm">
<Pressable
style={cn("flex-1 p-4")}
onPress={() => audioPlayerStore.setState({ expanded: true })}
>
<Text numberOfLines={1}>
{mediaTags?.tags?.title || filename || "..."}
</Text>
<Slider
minimumValue={0}
maximumValue={100}
value={
((status?.positionMillis || 0) / (status?.durationMillis || 1)) *
100
}
containerStyle={{ height: 20, marginTop: 4 }}
thumbStyle={cn("bg-transparent")}
trackStyle={cn("bg-primary/30 rounded-full h-2")}
minimumTrackTintColor="#6366F1"
/>
</Pressable>
<HStack className="pr-2">
<Button
variant="ghost"
size="icon"
icon={<Ionicons name="play-back" />}
onPress={audioPlayer.prev}
/>
<Button
variant="ghost"
size="icon"
icon={<Ionicons name={status?.isPlaying ? "pause" : "play"} />}
onPress={audioPlayer.togglePlay}
/>
<Button
variant="ghost"
size="icon"
icon={<Ionicons name="play-forward" />}
onPress={audioPlayer.next}
/>
</HStack>
</HStack>
</>
);
};
export default MiniPlayer;

View File

@ -54,7 +54,13 @@ export const getFileType = (path?: string | null) => {
}; };
export const getFilename = (path?: string | null) => { export const getFilename = (path?: string | null) => {
if (!path) {
return null;
}
let fname = path.split("/").pop()?.split(".").slice(0, -1).join("."); let fname = path.split("/").pop()?.split(".").slice(0, -1).join(".");
fname = fname.substring(0, fname.indexOf("?")); if (fname.indexOf("?") > -1) {
fname = fname.substring(0, fname.indexOf("?"));
}
return fname; return fname;
}; };

View File

@ -0,0 +1,111 @@
import { FileItem } from "@/types/files";
import { createStore } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { AVPlaybackStatusSuccess, Audio } from "expo-av";
import { MediaTags } from "@/types/mediaTags";
import { getFileType } from "@/lib/utils";
type PlaylistItem = FileItem & {
idx: number;
};
type AudioPlayerStore = {
playlist: PlaylistItem[];
currentIdx: number;
repeat: boolean;
shuffle: boolean;
sound: Audio.Sound | null;
status: AVPlaybackStatusSuccess | null;
mediaTags: MediaTags | null;
expanded: boolean;
};
export const audioPlayerStore = createStore(
persist<AudioPlayerStore>(
() => ({
playlist: [],
currentIdx: -1,
repeat: false,
shuffle: false,
sound: null,
status: null,
mediaTags: null,
expanded: false,
}),
{
name: "audioPlayer",
storage: createJSONStorage(() => AsyncStorage),
partialize: (state) => ({
...state,
sound: null,
status: null,
mediaTags: null,
expanded: false,
}),
}
)
);
const play = (files: FileItem[], idx: number) => {
const playlist: PlaylistItem[] = files
.map((f, idx) => ({ ...f, idx }))
.filter((f) => getFileType(f.name) === "audio");
const currentIdx = playlist.findIndex((f) => f.idx === idx);
audioPlayerStore.setState({
playlist: playlist.map((f, idx) => ({ ...f, idx })),
currentIdx,
status: null,
mediaTags: null,
});
};
const advanceBy = (increment: number) => {
const { playlist, currentIdx, shuffle } = audioPlayerStore.getState();
if (playlist.length > 0 && currentIdx >= 0) {
const idx = shuffle
? Math.floor(Math.random() * playlist.length)
: (currentIdx + increment) % playlist.length;
audioPlayerStore.setState({ currentIdx: idx });
}
};
const togglePlay = async () => {
const { sound, status } = audioPlayerStore.getState();
if (!sound) {
return;
}
if (status?.isPlaying) {
await sound.pauseAsync();
} else {
await sound.playAsync();
}
};
const next = () => advanceBy(1);
const prev = () => advanceBy(-1);
const seek = async (progress: number) => {
const { sound, status } = audioPlayerStore.getState();
if (!sound) {
return;
}
if (!status.isPlaying) {
await sound.playAsync();
}
const pos = (progress / 100.0) * (status?.durationMillis || 0);
sound.setPositionAsync(pos);
};
export const audioPlayer = {
store: audioPlayerStore,
play,
togglePlay,
prev,
next,
seek,
};