diff --git a/app/App.tsx b/app/App.tsx index a5b96ed..45c3de2 100644 --- a/app/App.tsx +++ b/app/App.tsx @@ -1,21 +1,27 @@ -import React from 'react' -import SplashPage from '@app/screens/SplashPage' import RootNavigator from '@app/navigation/RootNavigator' -import { Provider } from 'jotai' -import { StatusBar, View } from 'react-native' +import SplashPage from '@app/screens/SplashPage' import colors from '@app/styles/colors' -import TrackPlayerState from '@app/components/TrackPlayerState' +import React from 'react' +import { StatusBar, View } from 'react-native' +import ProgressHook from './components/ProgressHook' +import { useStore } from './state/store' +import { selectTrackPlayer } from './state/trackplayer' + +const Debug = () => { + const currentTrack = useStore(selectTrackPlayer.currentTrack) + console.log(currentTrack?.title) + return <> +} const App = () => ( - + - - - - - - - + + + + + + ) export default App diff --git a/app/components/CoverArt.tsx b/app/components/CoverArt.tsx index cca919b..f2a3062 100644 --- a/app/components/CoverArt.tsx +++ b/app/components/CoverArt.tsx @@ -36,8 +36,7 @@ const ArtistImageFallback: React.FC<{ }> = ({ enableLoading }) => { useEffect(() => { enableLoading() - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + }, [enableLoading]) return <> } diff --git a/app/components/ListItem.tsx b/app/components/ListItem.tsx index 475349f..5fe6d08 100644 --- a/app/components/ListItem.tsx +++ b/app/components/ListItem.tsx @@ -1,9 +1,9 @@ import { ListableItem } from '@app/models/music' -import { currentTrackAtom } from '@app/state/trackplayer' +import { useStore } from '@app/state/store' +import { selectTrackPlayer } from '@app/state/trackplayer' import colors from '@app/styles/colors' import font from '@app/styles/font' import { useNavigation } from '@react-navigation/native' -import { useAtomValue } from 'jotai/utils' import React, { useState } from 'react' import { GestureResponderEvent, StyleSheet, Text, View } from 'react-native' import IconFA from 'react-native-vector-icons/FontAwesome' @@ -16,7 +16,7 @@ const TitleTextSong = React.memo<{ id: string title?: string }>(({ id, title }) => { - const currentTrack = useAtomValue(currentTrackAtom) + const currentTrack = useStore(selectTrackPlayer.currentTrack) const playing = currentTrack?.id === id return ( diff --git a/app/components/ListPlayerControls.tsx b/app/components/ListPlayerControls.tsx index 2338ae5..bc833d0 100644 --- a/app/components/ListPlayerControls.tsx +++ b/app/components/ListPlayerControls.tsx @@ -1,6 +1,6 @@ import Button from '@app/components/Button' +import { useSetQueue } from '@app/hooks/trackplayer' import { Song } from '@app/models/music' -import { useSetQueue } from '@app/state/trackplayer' import colors from '@app/styles/colors' import React, { useState } from 'react' import { StyleProp, StyleSheet, View, ViewStyle } from 'react-native' diff --git a/app/components/NowPlayingBar.tsx b/app/components/NowPlayingBar.tsx index 21e88ed..8c165bd 100644 --- a/app/components/NowPlayingBar.tsx +++ b/app/components/NowPlayingBar.tsx @@ -1,17 +1,18 @@ -import React from 'react' -import { Pressable, StyleSheet, Text, View } from 'react-native' -import { useNavigation } from '@react-navigation/native' -import { useAtomValue } from 'jotai/utils' -import { currentTrackAtom, playerStateAtom, usePause, usePlay, useProgress } from '@app/state/trackplayer' import CoverArt from '@app/components/CoverArt' +import PressableOpacity from '@app/components/PressableOpacity' +import { usePause, usePlay } from '@app/hooks/trackplayer' +import { useStore } from '@app/state/store' +import { selectTrackPlayer } from '@app/state/trackplayer' import colors from '@app/styles/colors' import font from '@app/styles/font' +import { useNavigation } from '@react-navigation/native' +import React from 'react' +import { Pressable, StyleSheet, Text, View } from 'react-native' import { State } from 'react-native-track-player' -import PressableOpacity from '@app/components/PressableOpacity' import IconFA5 from 'react-native-vector-icons/FontAwesome5' const ProgressBar = () => { - const { position, duration } = useProgress() + const { position, duration } = useStore(selectTrackPlayer.progress) let progress = 0 if (duration > 0) { @@ -41,8 +42,8 @@ const progressStyles = StyleSheet.create({ const NowPlayingBar = () => { const navigation = useNavigation() - const track = useAtomValue(currentTrackAtom) - const playerState = useAtomValue(playerStateAtom) + const track = useStore(selectTrackPlayer.currentTrack) + const playerState = useStore(selectTrackPlayer.playerState) const play = usePlay() const pause = usePause() diff --git a/app/components/ProgressHook.tsx b/app/components/ProgressHook.tsx new file mode 100644 index 0000000..f7d82f5 --- /dev/null +++ b/app/components/ProgressHook.tsx @@ -0,0 +1,17 @@ +import { useStore } from '@app/state/store' +import { selectTrackPlayer } from '@app/state/trackplayer' +import React, { useEffect } from 'react' +import { useProgress } from 'react-native-track-player' + +const ProgressHook = () => { + const setProgress = useStore(selectTrackPlayer.setProgress) + const progress = useProgress(250) + + useEffect(() => { + setProgress(progress) + }, [setProgress, progress]) + + return <> +} + +export default ProgressHook diff --git a/app/components/TrackPlayerState.tsx b/app/components/TrackPlayerState.tsx deleted file mode 100644 index 2d9c261..0000000 --- a/app/components/TrackPlayerState.tsx +++ /dev/null @@ -1,192 +0,0 @@ -import { useAppState } from '@react-native-community/hooks' -import { useAtomValue, useUpdateAtom } from 'jotai/utils' -import React, { useEffect } from 'react' -import { View } from 'react-native' -import { Event, State, useProgress, useTrackPlayerEvents } from 'react-native-track-player' -import { - currentTrackAtom, - playerStateAtom, - progressAtom, - progressSubsAtom, - queueAtom, - useRefreshCurrentTrack, - useRefreshPlayerState, - useRefreshProgress, - useRefreshQueue, -} from '@app/state/trackplayer' - -const AppActiveResponder: React.FC<{ - update: () => void -}> = ({ update }) => { - const appState = useAppState() - - useEffect(() => { - if (appState === 'active') { - update() - } - }, [appState, update]) - - return <> -} - -type Payload = { type: Event; [key: string]: any } - -const TrackPlayerEventResponder: React.FC<{ - update: (payload?: Payload) => void - events: Event[] -}> = ({ update, events }) => { - useTrackPlayerEvents(events, update) - - return -} - -const CurrentTrackState = () => { - const setCurrentTrack = useUpdateAtom(currentTrackAtom) - const refreshCurrentTrack = useRefreshCurrentTrack() - - const update = async (payload?: Payload) => { - const queueEnded = payload?.type === Event.PlaybackQueueEnded && 'track' in payload - const remoteStop = payload?.type === Event.RemoteStop - if (queueEnded || remoteStop) { - setCurrentTrack(undefined) - return - } - await refreshCurrentTrack() - } - - return ( - - ) -} - -const PlayerState = () => { - const setPlayerState = useUpdateAtom(playerStateAtom) - const refreshPlayerState = useRefreshPlayerState() - - const update = async (payload?: Payload) => { - if (payload?.type === Event.RemoteStop) { - setPlayerState(State.None) - return - } - await refreshPlayerState() - } - - return -} - -const QueueState = () => { - const setQueue = useUpdateAtom(queueAtom) - const refreshQueue = useRefreshQueue() - - const update = async (payload?: Payload) => { - if (payload) { - setQueue([]) - return - } - await refreshQueue() - } - - return -} - -const ProgressHook = () => { - const setProgress = useUpdateAtom(progressAtom) - const progress = useProgress(250) - - useEffect(() => { - setProgress(progress) - }, [setProgress, progress]) - - return <> -} - -const ProgressState = () => { - const setProgress = useUpdateAtom(progressAtom) - const refreshProgress = useRefreshProgress() - const progressSubs = useAtomValue(progressSubsAtom) - - const update = async (payload?: Payload) => { - if (payload) { - setProgress({ position: 0, duration: 0, buffered: 0 }) - return - } - await refreshProgress() - } - - if (progressSubs > 0) { - return ( - <> - - - - ) - } - return -} - -const Debug = () => { - const value = useAtomValue(queueAtom) - - useEffect(() => { - console.log(value.map(t => t.title)) - }, [value]) - - return <> -} - -// const DebugEvents = () => { -// const update = (payload?: Payload) => { -// console.log(`${payload?.type}: ${JSON.stringify(payload)}`) -// } - -// return ( -// -// ) -// } - -const TrackPlayerState = () => ( - - - - - - - {/* */} - -) - -export default TrackPlayerState diff --git a/app/hooks/server.ts b/app/hooks/server.ts index f244b32..ea017ad 100644 --- a/app/hooks/server.ts +++ b/app/hooks/server.ts @@ -1,6 +1,6 @@ +import { useReset } from '@app/hooks/trackplayer' import { selectSettings } from '@app/state/settings' import { useStore } from '@app/state/store' -import { useReset } from '@app/state/trackplayer' import { useEffect } from 'react' export const useSwitchActiveServer = () => { diff --git a/app/hooks/trackplayer.ts b/app/hooks/trackplayer.ts new file mode 100644 index 0000000..4a51cd6 --- /dev/null +++ b/app/hooks/trackplayer.ts @@ -0,0 +1,201 @@ +import { useCoverArtUri } from '@app/hooks/music' +import { Song } from '@app/models/music' +import { useStore } from '@app/state/store' +import { getCurrentTrack, getQueue, selectTrackPlayer, TrackExt, trackPlayerCommands } from '@app/state/trackplayer' +import { useCallback } from 'react' +import TrackPlayer from 'react-native-track-player' + +export const usePlay = () => { + return () => trackPlayerCommands.enqueue(() => TrackPlayer.play()) +} + +export const usePause = () => { + return () => trackPlayerCommands.enqueue(() => TrackPlayer.pause()) +} + +export const usePrevious = () => { + // const setCurrentTrackIdx = useStore(selectTrackPlayer.setCurrentTrackIdx) + + return () => + trackPlayerCommands.enqueue(async () => { + const [current] = await Promise.all([await TrackPlayer.getCurrentTrack(), await getQueue()]) + if (current > 0) { + await TrackPlayer.skipToPrevious() + // setCurrentTrackIdx(current - 1) + } else { + await TrackPlayer.seekTo(0) + } + await TrackPlayer.play() + }) +} + +export const useNext = () => { + // const setCurrentTrack = useUpdateAtom(currentTrackAtom) + + return () => + trackPlayerCommands.enqueue(async () => { + const [current, queue] = await Promise.all([await TrackPlayer.getCurrentTrack(), await getQueue()]) + if (current >= queue.length - 1) { + await TrackPlayer.skip(0) + await TrackPlayer.pause() + // setCurrentTrack(queue[0]) + } else { + await TrackPlayer.skipToNext() + // setCurrentTrack(queue[current + 1]) + await TrackPlayer.play() + } + }) +} + +export const useReset = (enqueue = true) => { + const resetStore = useStore(selectTrackPlayer.reset) + + const reset = async () => { + await TrackPlayer.reset() + resetStore() + } + + return enqueue ? () => trackPlayerCommands.enqueue(reset) : reset +} + +function shuffleTracks(tracks: TrackExt[], firstTrack?: number) { + if (tracks.length === 0) { + return { tracks, shuffleOrder: [] } + } + + const trackIndexes = tracks.map((_t, i) => i) + let shuffleOrder: number[] = [] + + for (let i = trackIndexes.length; i--; i > 0) { + const randi = Math.floor(Math.random() * (i + 1)) + shuffleOrder.push(trackIndexes.splice(randi, 1)[0]) + } + + if (firstTrack !== undefined) { + shuffleOrder.splice(shuffleOrder.indexOf(firstTrack), 1) + shuffleOrder = [firstTrack, ...shuffleOrder] + } + + tracks = shuffleOrder.map(i => tracks[i]) + + return { tracks, shuffleOrder } +} + +function unshuffleTracks(tracks: TrackExt[], shuffleOrder: number[]): TrackExt[] { + if (tracks.length === 0 || shuffleOrder.length === 0) { + return tracks + } + + return shuffleOrder.map((_v, i) => tracks[shuffleOrder.indexOf(i)]) +} + +export const useToggleShuffle = () => { + const setQueue = useStore(selectTrackPlayer.setQueue) + const setShuffleOrder = useStore(selectTrackPlayer.setShuffleOrder) + const getShuffleOrder = useCallback(() => useStore.getState().shuffleOrder, []) + + return async () => { + return trackPlayerCommands.enqueue(async () => { + const queue = await getQueue() + const current = await getCurrentTrack() + const queueShuffleOrder = getShuffleOrder() + + await TrackPlayer.remove(queue.map((_t, i) => i).filter(i => i !== current)) + + if (queueShuffleOrder === undefined) { + let { tracks, shuffleOrder } = shuffleTracks(queue, current) + if (tracks.length > 0) { + tracks = tracks.slice(1) + } + + await TrackPlayer.add(tracks) + setShuffleOrder(shuffleOrder) + } else { + const tracks = unshuffleTracks(queue, queueShuffleOrder) + + if (current !== undefined) { + const shuffledCurrent = queueShuffleOrder[current] + const tracks1 = tracks.slice(0, shuffledCurrent) + const tracks2 = tracks.slice(shuffledCurrent + 1) + + await TrackPlayer.add(tracks2) + await TrackPlayer.add(tracks1, 0) + } else { + await TrackPlayer.add(tracks) + } + + setShuffleOrder(undefined) + } + + setQueue(await getQueue()) + }) + } +} + +export const useSetQueue = () => { + const setCurrentTrackIdx = useStore(selectTrackPlayer.setCurrentTrackIdx) + const setQueue = useStore(selectTrackPlayer.setQueue) + const setShuffleOrder = useStore(selectTrackPlayer.setShuffleOrder) + const setQueueName = useStore(selectTrackPlayer.setName) + const getQueueShuffled = useCallback(() => !!useStore.getState().shuffleOrder, []) + const coverArtUri = useCoverArtUri() + + return async (songs: Song[], name: string, playTrack?: number, shuffle?: boolean) => + trackPlayerCommands.enqueue(async () => { + const shuffled = shuffle !== undefined ? shuffle : getQueueShuffled() + + await TrackPlayer.setupPlayer() + await TrackPlayer.reset() + + if (songs.length === 0) { + return + } + + let queue = songs.map(s => mapSongToTrack(s, coverArtUri)) + + if (shuffled) { + const { tracks, shuffleOrder } = shuffleTracks(queue, playTrack) + setShuffleOrder(shuffleOrder) + queue = tracks + playTrack = 0 + } else { + setShuffleOrder(undefined) + } + + playTrack = playTrack || 0 + + setQueue(queue) + setCurrentTrackIdx(playTrack) + setQueueName(name) + + if (playTrack === 0) { + await TrackPlayer.add(queue) + await TrackPlayer.play() + } else { + const tracks1 = queue.slice(0, playTrack) + const tracks2 = queue.slice(playTrack) + + await TrackPlayer.add(tracks2) + await TrackPlayer.play() + + await TrackPlayer.add(tracks1, 0) + } + + // setQueue(await getQueue()) + // setCurrentTrackIdx(playTrack) + // setQueueName(name) + }) +} + +function mapSongToTrack(song: Song, coverArtUri: (coverArt?: string) => string | undefined): TrackExt { + return { + id: song.id, + title: song.title, + artist: song.artist || 'Unknown Artist', + album: song.album || 'Unknown Album', + url: song.streamUri, + artwork: coverArtUri(song.coverArt), + coverArt: song.coverArt, + duration: song.duration, + } +} diff --git a/app/playbackservice.ts b/app/playbackservice.ts new file mode 100644 index 0000000..cd9aaa5 --- /dev/null +++ b/app/playbackservice.ts @@ -0,0 +1,56 @@ +import { getCurrentTrack, getPlayerState, trackPlayerCommands } from '@app/state/trackplayer' +import TrackPlayer, { Event } from 'react-native-track-player' +import { useStore } from './state/store' + +module.exports = async function () { + TrackPlayer.addEventListener(Event.RemotePlay, () => trackPlayerCommands.enqueue(TrackPlayer.play)) + TrackPlayer.addEventListener(Event.RemotePause, () => trackPlayerCommands.enqueue(TrackPlayer.pause)) + + TrackPlayer.addEventListener(Event.RemoteNext, () => + trackPlayerCommands.enqueue(() => TrackPlayer.skipToNext().catch(() => {})), + ) + TrackPlayer.addEventListener(Event.RemotePrevious, () => + trackPlayerCommands.enqueue(() => TrackPlayer.skipToPrevious().catch(() => {})), + ) + + TrackPlayer.addEventListener(Event.RemoteDuck, data => { + if (data.permanent) { + trackPlayerCommands.enqueue(TrackPlayer.stop) + return + } + + if (data.paused) { + trackPlayerCommands.enqueue(TrackPlayer.pause) + } else { + trackPlayerCommands.enqueue(TrackPlayer.play) + } + }) + + TrackPlayer.addEventListener(Event.RemoteStop, () => { + useStore.getState().reset() + trackPlayerCommands.enqueue(TrackPlayer.destroy) + }) + + TrackPlayer.addEventListener(Event.PlaybackState, () => { + trackPlayerCommands.enqueue(async () => { + useStore.getState().setPlayerState(await getPlayerState()) + }) + }) + + TrackPlayer.addEventListener(Event.PlaybackTrackChanged, () => { + useStore.getState().setProgress({ position: 0, duration: 0, buffered: 0 }) + trackPlayerCommands.enqueue(async () => { + useStore.getState().setCurrentTrackIdx(await getCurrentTrack()) + }) + }) + + TrackPlayer.addEventListener(Event.PlaybackQueueEnded, () => { + trackPlayerCommands.enqueue(async () => { + useStore.getState().setCurrentTrackIdx(await getCurrentTrack()) + }) + }) + + TrackPlayer.addEventListener(Event.PlaybackMetadataReceived, () => { + useStore.getState().setCurrentTrackIdx(useStore.getState().currentTrackIdx) + }) +} diff --git a/app/screens/ArtistView.tsx b/app/screens/ArtistView.tsx index a3c2449..da7e075 100644 --- a/app/screens/ArtistView.tsx +++ b/app/screens/ArtistView.tsx @@ -4,8 +4,8 @@ import Header from '@app/components/Header' import ListItem from '@app/components/ListItem' import PressableOpacity from '@app/components/PressableOpacity' import { useArtistInfo } from '@app/hooks/music' +import { useSetQueue } from '@app/hooks/trackplayer' import { Album, Song } from '@app/models/music' -import { useSetQueue } from '@app/state/trackplayer' import colors from '@app/styles/colors' import font from '@app/styles/font' import { useLayout } from '@react-native-community/hooks' diff --git a/app/screens/NowPlayingView.tsx b/app/screens/NowPlayingView.tsx index b6af544..5a46bc8 100644 --- a/app/screens/NowPlayingView.tsx +++ b/app/screens/NowPlayingView.tsx @@ -1,4 +1,3 @@ -import { useAtomValue } from 'jotai/utils' import React, { useCallback, useEffect } from 'react' import { BackHandler, StatusBar, StyleSheet, Text, View } from 'react-native' import { State } from 'react-native-track-player' @@ -7,18 +6,6 @@ import IconFA5 from 'react-native-vector-icons/FontAwesome5' import Icon from 'react-native-vector-icons/Ionicons' import IconMatCom from 'react-native-vector-icons/MaterialCommunityIcons' import IconMat from 'react-native-vector-icons/MaterialIcons' -import { - currentTrackAtom, - playerStateAtom, - queueNameAtom, - queueShuffledAtom, - useNext, - usePause, - usePlay, - usePrevious, - useProgress, - useToggleShuffle, -} from '@app/state/trackplayer' import colors from '@app/styles/colors' import font from '@app/styles/font' import formatDuration from '@app/util/formatDuration' @@ -28,11 +15,14 @@ import PressableOpacity from '@app/components/PressableOpacity' import dimensions from '@app/styles/dimensions' import { NativeStackScreenProps } from 'react-native-screens/native-stack' import { useFocusEffect } from '@react-navigation/native' +import { useStore } from '@app/state/store' +import { selectTrackPlayer } from '@app/state/trackplayer' +import { useNext, usePause, usePlay, usePrevious, useToggleShuffle } from '@app/hooks/trackplayer' const NowPlayingHeader = React.memo<{ backHandler: () => void }>(({ backHandler }) => { - const queueName = useAtomValue(queueNameAtom) + const queueName = useStore(selectTrackPlayer.name) return ( @@ -72,7 +62,7 @@ const headerStyles = StyleSheet.create({ }) const SongCoverArt = () => { - const track = useAtomValue(currentTrackAtom) + const track = useStore(selectTrackPlayer.currentTrack) return ( @@ -94,7 +84,7 @@ const coverArtStyles = StyleSheet.create({ }) const SongInfo = () => { - const track = useAtomValue(currentTrackAtom) + const track = useStore(selectTrackPlayer.currentTrack) return ( @@ -142,7 +132,7 @@ const infoStyles = StyleSheet.create({ }) const SeekBar = () => { - const { position, duration } = useProgress() + const { position, duration } = useStore(selectTrackPlayer.progress) let progress = 0 if (duration > 0) { @@ -204,12 +194,12 @@ const seekStyles = StyleSheet.create({ }) const PlayerControls = () => { - const state = useAtomValue(playerStateAtom) + const state = useStore(selectTrackPlayer.playerState) const play = usePlay() const pause = usePause() const next = useNext() const previous = usePrevious() - const shuffle = useAtomValue(queueShuffledAtom) + const shuffled = useStore(selectTrackPlayer.shuffled) const toggleShuffle = useToggleShuffle() let playPauseIcon: string @@ -254,7 +244,7 @@ const PlayerControls = () => { toggleShuffle()} disabled={disabled}> - + @@ -304,7 +294,7 @@ type RootStackParamList = { type NowPlayingProps = NativeStackScreenProps const NowPlayingView: React.FC = ({ navigation }) => { - const track = useAtomValue(currentTrackAtom) + const track = useStore(selectTrackPlayer.currentTrack) const back = useCallback(() => { if (navigation.canGoBack()) { diff --git a/app/screens/Search.tsx b/app/screens/Search.tsx index 3a81be2..256bf91 100644 --- a/app/screens/Search.tsx +++ b/app/screens/Search.tsx @@ -3,10 +3,10 @@ import Header from '@app/components/Header' import ListItem from '@app/components/ListItem' import NothingHere from '@app/components/NothingHere' import { useActiveListRefresh2 } from '@app/hooks/server' +import { useSetQueue } from '@app/hooks/trackplayer' import { ListableItem, SearchResults, Song } from '@app/models/music' import { selectMusic } from '@app/state/music' import { useStore } from '@app/state/store' -import { useSetQueue } from '@app/state/trackplayer' import colors from '@app/styles/colors' import font from '@app/styles/font' import debounce from 'lodash.debounce' diff --git a/app/screens/SongListView.tsx b/app/screens/SongListView.tsx index 15bdf7c..ff42197 100644 --- a/app/screens/SongListView.tsx +++ b/app/screens/SongListView.tsx @@ -5,8 +5,8 @@ import ListItem from '@app/components/ListItem' import ListPlayerControls from '@app/components/ListPlayerControls' import NothingHere from '@app/components/NothingHere' import { useAlbumWithSongs, useCoverArtUri, usePlaylistWithSongs } from '@app/hooks/music' +import { useSetQueue } from '@app/hooks/trackplayer' import { AlbumWithSongs, PlaylistWithSongs, Song } from '@app/models/music' -import { useSetQueue } from '@app/state/trackplayer' import colors from '@app/styles/colors' import font from '@app/styles/font' import { useNavigation } from '@react-navigation/native' @@ -46,7 +46,7 @@ const Songs = React.memo<{ return ( <> - + {_songs.map((s, i) => ( { const exists = await RNFS.exists(path) @@ -17,8 +18,11 @@ async function mkdir(path: string): Promise { return await RNFS.mkdir(path) } +const selectHydrated = (store: Store) => store.hydrated + const SplashPage: React.FC<{}> = ({ children }) => { const [ready, setReady] = useState(false) + const hydrated = useStore(selectHydrated) const minSplashTime = new Promise(resolve => setTimeout(resolve, 1)) @@ -36,10 +40,11 @@ const SplashPage: React.FC<{}> = ({ children }) => { }) }) - if (!ready) { + if (ready && hydrated) { + return {children} + } else { return Loading THE GOOD SHIT... } - return {children} } export default SplashPage diff --git a/app/service.ts b/app/service.ts deleted file mode 100644 index 4de807f..0000000 --- a/app/service.ts +++ /dev/null @@ -1,28 +0,0 @@ -import TrackPlayer, { Event } from 'react-native-track-player' -import { trackPlayerCommands } from '@app/state/trackplayer' - -module.exports = async function () { - TrackPlayer.addEventListener(Event.RemotePlay, () => trackPlayerCommands.enqueue(TrackPlayer.play)) - TrackPlayer.addEventListener(Event.RemotePause, () => trackPlayerCommands.enqueue(TrackPlayer.pause)) - TrackPlayer.addEventListener(Event.RemoteStop, () => trackPlayerCommands.enqueue(TrackPlayer.destroy)) - - TrackPlayer.addEventListener(Event.RemoteDuck, data => { - if (data.permanent) { - trackPlayerCommands.enqueue(TrackPlayer.stop) - return - } - - if (data.paused) { - trackPlayerCommands.enqueue(TrackPlayer.pause) - } else { - trackPlayerCommands.enqueue(TrackPlayer.play) - } - }) - - TrackPlayer.addEventListener(Event.RemoteNext, () => - trackPlayerCommands.enqueue(() => TrackPlayer.skipToNext().catch(() => {})), - ) - TrackPlayer.addEventListener(Event.RemotePrevious, () => - trackPlayerCommands.enqueue(() => TrackPlayer.skipToPrevious().catch(() => {})), - ) -} diff --git a/app/state/store.ts b/app/state/store.ts index b799657..d2a3cb9 100644 --- a/app/state/store.ts +++ b/app/state/store.ts @@ -3,8 +3,14 @@ import { createSettingsSlice, SettingsSlice } from '@app/state/settings' import AsyncStorage from '@react-native-async-storage/async-storage' import create from 'zustand' import { persist, StateStorage } from 'zustand/middleware' +import { createTrackPlayerSlice, TrackPlayerSlice } from './trackplayer' -export type Store = SettingsSlice & MusicSlice +export type Store = SettingsSlice & + MusicSlice & + TrackPlayerSlice & { + hydrated: boolean + setHydrated: (hydrated: boolean) => void + } const storage: StateStorage = { getItem: async name => { @@ -29,6 +35,10 @@ export const useStore = create( (set, get) => ({ ...createSettingsSlice(set, get), ...createMusicSlice(set, get), + ...createTrackPlayerSlice(set, get), + + hydrated: false, + setHydrated: hydrated => set({ hydrated }), }), { name: '@appStore', @@ -37,6 +47,7 @@ export const useStore = create( onRehydrateStorage: _preState => { return (postState, _error) => { postState?.createClient(postState.settings.activeServer) + postState?.setHydrated(true) } }, }, diff --git a/app/state/trackplayer.ts b/app/state/trackplayer.ts index 9348d52..5c49a99 100644 --- a/app/state/trackplayer.ts +++ b/app/state/trackplayer.ts @@ -1,388 +1,119 @@ -import { useCoverArtUri } from '@app/hooks/music' -import { Song } from '@app/models/music' import PromiseQueue from '@app/util/PromiseQueue' -import equal from 'fast-deep-equal' -import { atom } from 'jotai' -import { useAtomCallback, useAtomValue, useUpdateAtom } from 'jotai/utils' -import { atomWithStore } from 'jotai/zustand' -import { useCallback, useEffect } from 'react' +import produce from 'immer' import TrackPlayer, { State, Track } from 'react-native-track-player' -import create from 'zustand' +import { GetState, SetState } from 'zustand' +import { Store } from './store' -type TrackExt = Track & { +export type TrackExt = Track & { id: string coverArt?: string } -type OptionalTrackExt = TrackExt | undefined - -type Progress = { +export type Progress = { position: number duration: number buffered: number } -type QueueStore = { +export type TrackPlayerSlice = { name?: string setName: (name?: string) => void + shuffleOrder?: number[] setShuffleOrder: (shuffleOrder?: number[]) => void - shuffled: () => boolean + + playerState: State + setPlayerState: (playerState: State) => void + + currentTrack?: TrackExt + currentTrackIdx?: number + setCurrentTrackIdx: (idx?: number) => void + + queue: TrackExt[] + setQueue: (queue: TrackExt[]) => void + + progress: Progress + setProgress: (progress: Progress) => void + reset: () => void } -const useStore = create((set, get) => ({ - name: undefined, - setName: (name?: string) => set({ name }), - shuffleOrder: undefined, - setShuffleOrder: (shuffleOrder?: number[]) => set({ shuffleOrder }), - shuffled: () => !!get().shuffleOrder, - reset: () => set({ name: undefined, shuffleOrder: undefined }), -})) +export const selectTrackPlayer = { + name: (store: TrackPlayerSlice) => store.name, + setName: (store: TrackPlayerSlice) => store.setName, -const queueStoreAtom = atomWithStore(useStore) + shuffleOrder: (store: TrackPlayerSlice) => store.shuffleOrder, + setShuffleOrder: (store: TrackPlayerSlice) => store.setShuffleOrder, + shuffled: (store: TrackPlayerSlice) => !!store.shuffleOrder, -export const queueNameAtom = atom( - get => get(queueStoreAtom).name, - (get, set, update) => { - get(queueStoreAtom).setName(update) - }, -) + playerState: (store: TrackPlayerSlice) => store.playerState, + setPlayerState: (store: TrackPlayerSlice) => store.setPlayerState, -const queueShuffleOrderAtom = atom( - get => get(queueStoreAtom).shuffleOrder, - (get, set, update) => { - get(queueStoreAtom).setShuffleOrder(update) - }, -) + currentTrack: (store: TrackPlayerSlice) => store.currentTrack, + currentTrackIdx: (store: TrackPlayerSlice) => store.currentTrackIdx, + setCurrentTrackIdx: (store: TrackPlayerSlice) => store.setCurrentTrackIdx, -export const queueShuffledAtom = atom(get => get(queueStoreAtom).shuffled()) + queue: (store: TrackPlayerSlice) => store.queue, + setQueue: (store: TrackPlayerSlice) => store.setQueue, -const playerState = atom(State.None) -export const playerStateAtom = atom( - get => get(playerState), - (get, set, update) => { - if (get(playerState) !== update) { - set(playerState, update) - } - }, -) + progress: (store: TrackPlayerSlice) => store.progress, + setProgress: (store: TrackPlayerSlice) => store.setProgress, -const currentTrack = atom(undefined) -export const currentTrackAtom = atom( - get => get(currentTrack), - (get, set, update) => { - if (!equal(get(currentTrack), update)) { - set(currentTrack, update) - } - }, -) - -const _queue = atom([]) -export const queueAtom = atom( - get => get(_queue), - (get, set, update) => { - if (!equal(get(_queue), update)) { - set(_queue, update) - } - }, -) - -const _progress = atom({ position: 0, duration: 0, buffered: 0 }) -export const progressAtom = atom( - get => get(_progress), - (get, set, update) => { - if (!equal(get(_progress), update)) { - set(_progress, update) - } - }, -) - -const progressSubs = atom(0) -export const progressSubsAtom = atom(get => get(progressSubs)) -const addProgressSub = atom(null, (get, set) => { - set(progressSubs, get(progressSubs) + 1) -}) -const removeProgressSub = atom(null, (get, set) => { - set(progressSubs, get(progressSubs) - 1) -}) + reset: (store: TrackPlayerSlice) => store.reset, +} export const trackPlayerCommands = new PromiseQueue(1) -const getQueue = async (): Promise => { +export const createTrackPlayerSlice = (set: SetState, _get: GetState): TrackPlayerSlice => ({ + name: undefined, + setName: name => set({ name }), + + shuffleOrder: undefined, + setShuffleOrder: shuffleOrder => set({ shuffleOrder }), + + playerState: State.None, + setPlayerState: playerState => set({ playerState }), + + currentTrack: undefined, + currentTrackIdx: undefined, + setCurrentTrackIdx: idx => { + set( + produce(state => { + state.currentTrackIdx = idx + state.currentTrack = idx !== undefined ? state.queue[idx] : undefined + }), + ) + }, + + queue: [], + setQueue: queue => set({ queue }), + + progress: { position: 0, duration: 0, buffered: 0 }, + setProgress: progress => set({ progress }), + + reset: () => { + set({ + name: undefined, + shuffleOrder: undefined, + playerState: State.None, + currentTrack: undefined, + currentTrackIdx: undefined, + queue: [], + progress: { position: 0, duration: 0, buffered: 0 }, + }) + }, +}) + +export const getQueue = async (): Promise => { return ((await TrackPlayer.getQueue()) as TrackExt[]) || [] } -const getTrack = async (index: number): Promise => { - return ((await TrackPlayer.getTrack(index)) as TrackExt) || undefined -} - -const getCurrentTrack = async (): Promise => { +export const getCurrentTrack = async (): Promise => { const current = await TrackPlayer.getCurrentTrack() return typeof current === 'number' ? current : undefined } -const getPlayerState = async (): Promise => { - return (await TrackPlayer.getState()) || State.None -} - -const getProgress = async (): Promise => { - const [position, duration, buffered] = await Promise.all([ - TrackPlayer.getPosition(), - TrackPlayer.getDuration(), - TrackPlayer.getBufferedPosition(), - ]) - return { - position: position || 0, - duration: duration || 0, - buffered: buffered || 0, - } -} - -export const useRefreshQueue = () => { - const setQueue = useUpdateAtom(queueAtom) - - return () => - trackPlayerCommands.enqueue(async () => { - setQueue(await getQueue()) - }) -} - -export const useRefreshCurrentTrack = () => { - const setCurrentTrack = useUpdateAtom(currentTrackAtom) - - return () => - trackPlayerCommands.enqueue(async () => { - const index = await TrackPlayer.getCurrentTrack() - if (typeof index === 'number' && index >= 0) { - setCurrentTrack(await getTrack(index)) - } else { - setCurrentTrack(undefined) - } - }) -} - -export const useRefreshPlayerState = () => { - const setPlayerState = useUpdateAtom(playerStateAtom) - - return () => - trackPlayerCommands.enqueue(async () => { - setPlayerState(await getPlayerState()) - }) -} - -export const useRefreshProgress = () => { - const setProgress = useUpdateAtom(progressAtom) - - return () => - trackPlayerCommands.enqueue(async () => { - setProgress(await getProgress()) - }) -} - -export const usePlay = () => { - return () => trackPlayerCommands.enqueue(() => TrackPlayer.play()) -} - -export const usePause = () => { - return () => trackPlayerCommands.enqueue(() => TrackPlayer.pause()) -} - -export const usePrevious = () => { - const setCurrentTrack = useUpdateAtom(currentTrackAtom) - - return () => - trackPlayerCommands.enqueue(async () => { - const [current, queue] = await Promise.all([await TrackPlayer.getCurrentTrack(), await getQueue()]) - if (current > 0) { - await TrackPlayer.skipToPrevious() - setCurrentTrack(queue[current - 1]) - } else { - await TrackPlayer.seekTo(0) - } - await TrackPlayer.play() - }) -} - -export const useNext = () => { - const setCurrentTrack = useUpdateAtom(currentTrackAtom) - - return () => - trackPlayerCommands.enqueue(async () => { - const [current, queue] = await Promise.all([await TrackPlayer.getCurrentTrack(), await getQueue()]) - if (current >= queue.length - 1) { - await TrackPlayer.skip(0) - await TrackPlayer.pause() - setCurrentTrack(queue[0]) - } else { - await TrackPlayer.skipToNext() - setCurrentTrack(queue[current + 1]) - await TrackPlayer.play() - } - }) -} - -export const useReset = (enqueue = true) => { - const setQueue = useUpdateAtom(queueAtom) - const setCurrentTrack = useUpdateAtom(currentTrackAtom) - const resetQueueStore = useStore(state => state.reset) - - const reset = async () => { - await TrackPlayer.reset() - setQueue([]) - setCurrentTrack(undefined) - resetQueueStore() - } - - return enqueue ? () => trackPlayerCommands.enqueue(reset) : reset -} - -function shuffleTracks(tracks: TrackExt[], firstTrack?: number) { - if (tracks.length === 0) { - return { tracks, shuffleOrder: [] } - } - - const trackIndexes = tracks.map((_t, i) => i) - let shuffleOrder: number[] = [] - - for (let i = trackIndexes.length; i--; i > 0) { - const randi = Math.floor(Math.random() * (i + 1)) - shuffleOrder.push(trackIndexes.splice(randi, 1)[0]) - } - - if (firstTrack !== undefined) { - shuffleOrder.splice(shuffleOrder.indexOf(firstTrack), 1) - shuffleOrder = [firstTrack, ...shuffleOrder] - } - - tracks = shuffleOrder.map(i => tracks[i]) - - return { tracks, shuffleOrder } -} - -function unshuffleTracks(tracks: TrackExt[], shuffleOrder: number[]): TrackExt[] { - if (tracks.length === 0 || shuffleOrder.length === 0) { - return tracks - } - - return shuffleOrder.map((_v, i) => tracks[shuffleOrder.indexOf(i)]) -} - -export const useToggleShuffle = () => { - const setQueue = useUpdateAtom(queueAtom) - const setQueueShuffleOrder = useUpdateAtom(queueShuffleOrderAtom) - const getQueueShuffleOrder = useAtomCallback(useCallback(get => get(queueShuffleOrderAtom), [])) - - return async () => { - return trackPlayerCommands.enqueue(async () => { - const queue = await getQueue() - const current = await getCurrentTrack() - const queueShuffleOrder = await getQueueShuffleOrder() - - await TrackPlayer.remove(queue.map((_t, i) => i).filter(i => i !== current)) - - if (queueShuffleOrder === undefined) { - let { tracks, shuffleOrder } = shuffleTracks(queue, current) - if (tracks.length > 0) { - tracks = tracks.slice(1) - } - - await TrackPlayer.add(tracks) - setQueueShuffleOrder(shuffleOrder) - } else { - const tracks = unshuffleTracks(queue, queueShuffleOrder) - - if (current !== undefined) { - const shuffledCurrent = queueShuffleOrder[current] - const tracks1 = tracks.slice(0, shuffledCurrent) - const tracks2 = tracks.slice(shuffledCurrent + 1) - - await TrackPlayer.add(tracks2) - await TrackPlayer.add(tracks1, 0) - } else { - await TrackPlayer.add(tracks) - } - - setQueueShuffleOrder(undefined) - } - - setQueue(await getQueue()) - }) - } -} - -export const useSetQueue = () => { - const setCurrentTrack = useUpdateAtom(currentTrackAtom) - const setQueue = useUpdateAtom(queueAtom) - const setQueueShuffleOrder = useUpdateAtom(queueShuffleOrderAtom) - const setQueueName = useUpdateAtom(queueNameAtom) - const reset = useReset(false) - const getQueueShuffled = useAtomCallback(useCallback(get => get(queueShuffledAtom), [])) - const coverArtUri = useCoverArtUri() - - return async (songs: Song[], name: string, playTrack?: number, shuffle?: boolean) => - trackPlayerCommands.enqueue(async () => { - const shuffled = shuffle !== undefined ? shuffle : await getQueueShuffled() - - await TrackPlayer.setupPlayer() - await reset() - - if (songs.length === 0) { - return - } - - let queue = songs.map(s => mapSongToTrack(s, coverArtUri)) - - if (shuffled) { - const { tracks, shuffleOrder } = shuffleTracks(queue, playTrack) - setQueueShuffleOrder(shuffleOrder) - queue = tracks - playTrack = 0 - } - - playTrack = playTrack || 0 - setCurrentTrack(queue[playTrack]) - - if (playTrack === 0) { - await TrackPlayer.add(queue) - await TrackPlayer.play() - } else { - const tracks1 = queue.slice(0, playTrack) - const tracks2 = queue.slice(playTrack) - - await TrackPlayer.add(tracks2) - await TrackPlayer.play() - - await TrackPlayer.add(tracks1, 0) - } - - setQueue(await getQueue()) - setQueueName(name) - }) -} - -export const useProgress = () => { - const progress = useAtomValue(progressAtom) - const addSub = useUpdateAtom(addProgressSub) - const removeSub = useUpdateAtom(removeProgressSub) - - useEffect(() => { - addSub() - return removeSub - }, [addSub, removeSub]) - - return progress -} - -function mapSongToTrack(song: Song, coverArtUri: (coverArt?: string) => string | undefined): TrackExt { - return { - id: song.id, - title: song.title, - artist: song.artist || 'Unknown Artist', - album: song.album || 'Unknown Album', - url: song.streamUri, - artwork: coverArtUri(song.coverArt), - coverArt: song.coverArt, - duration: song.duration, - } +export const getPlayerState = async (): Promise => { + const state = await TrackPlayer.getState() + return state || State.None } diff --git a/app/storage/atomWithAsyncStorage.ts b/app/storage/atomWithAsyncStorage.ts deleted file mode 100644 index 7c9c547..0000000 --- a/app/storage/atomWithAsyncStorage.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { atomWithStorage } from 'jotai/utils' -import { getItem, setItem } from '@app/storage/asyncstorage' - -export default (key: string, defaultValue: T) => { - return atomWithStorage(key, defaultValue, { - getItem: async () => (await getItem(key)) || defaultValue, - setItem: setItem, - delayInit: true, - }) -} diff --git a/app/subsonic/hooks.ts b/app/subsonic/hooks.ts deleted file mode 100644 index bffde41..0000000 --- a/app/subsonic/hooks.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { useAtomValue } from 'jotai/utils' -import { activeServerAtom } from '@app/state/settings' -import { SubsonicApiClient } from '@app/subsonic/api' - -export const useSubsonicApi = () => { - const activeServer = useAtomValue(activeServerAtom) - - return () => { - if (!activeServer) { - return undefined - } - return new SubsonicApiClient(activeServer) - } -} diff --git a/index.js b/index.js index 48b4682..caa0bf8 100644 --- a/index.js +++ b/index.js @@ -13,7 +13,7 @@ import { name as appName } from '@app/app.json' import TrackPlayer, { Capability } from 'react-native-track-player' AppRegistry.registerComponent(appName, () => App) -TrackPlayer.registerPlaybackService(() => require('@app/service')) +TrackPlayer.registerPlaybackService(() => require('@app/playbackservice')) async function start() { await TrackPlayer.setupPlayer() diff --git a/package.json b/package.json index 5b5b8d4..cabda6d 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,6 @@ "@react-navigation/native": "^5.9.4", "fast-deep-equal": "^3.1.3", "immer": "^9.0.5", - "jotai": "^1.1.0", "lodash.debounce": "^4.0.8", "md5": "^2.3.0", "react": "17.0.1", diff --git a/yarn.lock b/yarn.lock index 439cfba..ada50b2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4161,11 +4161,6 @@ joi@^17.2.1: "@sideway/formula" "^3.0.0" "@sideway/pinpoint" "^2.0.0" -jotai@^1.1.0: - version "1.2.2" - resolved "https://registry.yarnpkg.com/jotai/-/jotai-1.2.2.tgz#631fd7ad44e9ac26cdf9874d52282c1cfe032807" - integrity sha512-iqkkUdWsH2Mk4HY1biba/8kA77+8liVBy8E0d8Nce29qow4h9mzdDhpTasAruuFYPycw6JvfZgL5RB0JJuIZjw== - "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"