mirror of
https://github.com/khairul169/home-lab.git
synced 2025-04-28 16:49:36 +07:00
feat: new music player ui
This commit is contained in:
parent
ba474ceffe
commit
fae66903a0
@ -1,5 +1,5 @@
|
||||
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 queryClient from "@/lib/queryClient";
|
||||
import { View } from "react-native";
|
||||
@ -12,6 +12,9 @@ import { useStore } from "zustand";
|
||||
import authStore from "@/stores/authStore";
|
||||
import { toastStore } from "@/stores/toastStore";
|
||||
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 insets = useSafeAreaInsets();
|
||||
@ -40,13 +43,15 @@ const RootLayout = () => {
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<StatusBar style="auto" />
|
||||
<View
|
||||
style={cn("flex-1 bg-[#f2f7fb] overflow-hidden", {
|
||||
style={cn("flex-1 bg-[#f2f7fb] overflow-hidden relative", {
|
||||
paddingTop: insets.top,
|
||||
})}
|
||||
>
|
||||
<Stack
|
||||
screenOptions={{ contentStyle: { backgroundColor: "#f2f7fb" } }}
|
||||
/>
|
||||
<MiniPlayer />
|
||||
<AudioPlayer />
|
||||
</View>
|
||||
<Toast
|
||||
ref={(ref) => {
|
||||
@ -54,6 +59,7 @@ const RootLayout = () => {
|
||||
}}
|
||||
/>
|
||||
<Dialog />
|
||||
<AudioPlayerProvider />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
@ -19,6 +19,8 @@ import Head from "@/components/utility/Head";
|
||||
import Container from "@ui/Container";
|
||||
import FileUpload from "@/components/pages/files/FileUpload";
|
||||
import ActionButton from "@/components/pages/files/ActionButton";
|
||||
import { getFileType } from "@/lib/utils";
|
||||
import { audioPlayer } from "@/stores/audioPlayerStore";
|
||||
|
||||
const FilesPage = () => {
|
||||
const { isLoggedIn } = useAuth();
|
||||
@ -105,12 +107,17 @@ const FilesPage = () => {
|
||||
<FileDrop onFileDrop={onFileDrop} isDisabled={upload.isLoading}>
|
||||
<FileList
|
||||
files={data}
|
||||
onSelect={(file) => {
|
||||
onSelect={(file, idx) => {
|
||||
if (file.isDirectory) {
|
||||
setParams({ path: file.path });
|
||||
} else {
|
||||
setViewFile(file);
|
||||
return setParams({ path: file.path });
|
||||
}
|
||||
|
||||
const fileType = getFileType(file.path);
|
||||
if (fileType === "audio") {
|
||||
return audioPlayer.play(data, idx);
|
||||
}
|
||||
|
||||
setViewFile(file);
|
||||
}}
|
||||
/>
|
||||
</FileDrop>
|
||||
|
@ -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;
|
94
src/components/containers/AudioPlayerProvider.tsx
Normal file
94
src/components/containers/AudioPlayerProvider.tsx
Normal 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;
|
@ -9,7 +9,6 @@ import Modal from "react-native-modal";
|
||||
import { Video, ResizeMode } from "expo-av";
|
||||
import { getFileUrl, openFile } from "@/app/apps/lib";
|
||||
import { Image } from "react-native";
|
||||
import AudioPlayer from "@/components/containers/AudioPlayer";
|
||||
import { FileItem } from "@/types/files";
|
||||
|
||||
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") {
|
||||
return (
|
||||
<Image
|
||||
|
@ -11,9 +11,9 @@ import FileMenu, { openFileMenu } from "./FileMenu";
|
||||
|
||||
type FileListProps = {
|
||||
files?: FileItem[];
|
||||
onSelect?: (file: FileItem) => void;
|
||||
onSelect?: (file: FileItem, idx: number) => void;
|
||||
// onMenu?: (file: FileItem) => void;
|
||||
onLongPress?: (file: FileItem) => void;
|
||||
onLongPress?: (file: FileItem, idx: number) => void;
|
||||
};
|
||||
|
||||
const FileList = ({ files, onSelect, onLongPress }: FileListProps) => {
|
||||
@ -23,11 +23,11 @@ const FileList = ({ files, onSelect, onLongPress }: FileListProps) => {
|
||||
style={cn("flex-1")}
|
||||
contentContainerStyle={cn("bg-white")}
|
||||
data={files || []}
|
||||
renderItem={({ item }) => (
|
||||
renderItem={({ item, index }) => (
|
||||
<FileItemList
|
||||
file={item}
|
||||
onPress={() => onSelect?.(item)}
|
||||
onLongPress={() => onLongPress?.(item)}
|
||||
onPress={() => onSelect?.(item, index)}
|
||||
onLongPress={() => onLongPress?.(item, index)}
|
||||
onMenuPress={() => openFileMenu(item)}
|
||||
/>
|
||||
)}
|
||||
|
@ -7,6 +7,7 @@ import Button from "@ui/Button";
|
||||
import { useNavigation } from "expo-router";
|
||||
import { showDialog } from "@/stores/dialogStore";
|
||||
import { wakePcUp } from "@/app/apps/lib";
|
||||
import { audioPlayerStore } from "@/stores/audioPlayerStore";
|
||||
|
||||
type Props = ComponentProps<typeof Box>;
|
||||
|
||||
@ -19,6 +20,11 @@ const Apps = (props: Props) => {
|
||||
icon: <Ionicons name="folder" />,
|
||||
path: "files",
|
||||
},
|
||||
{
|
||||
name: "Music",
|
||||
icon: <Ionicons name="musical-notes" />,
|
||||
action: () => audioPlayerStore.setState({ expanded: true }),
|
||||
},
|
||||
{
|
||||
name: "Terminal",
|
||||
icon: <Ionicons name="terminal" />,
|
||||
|
252
src/components/pages/music/AudioPlayer.tsx
Normal file
252
src/components/pages/music/AudioPlayer.tsx
Normal 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);
|
73
src/components/pages/music/MiniPlayer.tsx
Normal file
73
src/components/pages/music/MiniPlayer.tsx
Normal 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;
|
@ -54,7 +54,13 @@ export const getFileType = (path?: string | null) => {
|
||||
};
|
||||
|
||||
export const getFilename = (path?: string | null) => {
|
||||
if (!path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
|
111
src/stores/audioPlayerStore.ts
Normal file
111
src/stores/audioPlayerStore.ts
Normal 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,
|
||||
};
|
Loading…
x
Reference in New Issue
Block a user