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 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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -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>
|
||||||
|
@ -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 { 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
|
||||||
|
@ -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)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
@ -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" />,
|
||||||
|
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) => {
|
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(".");
|
||||||
|
if (fname.indexOf("?") > -1) {
|
||||||
fname = fname.substring(0, fname.indexOf("?"));
|
fname = fname.substring(0, fname.indexOf("?"));
|
||||||
|
}
|
||||||
return fname;
|
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