trying to centralize all player logic around state

This commit is contained in:
austinried 2021-07-06 09:49:15 +09:00
parent acc7759495
commit eb4199de37
5 changed files with 302 additions and 141 deletions

View File

@ -1,17 +1,18 @@
import { useAtomValue } from 'jotai/utils'
import React from 'react'
import { Pressable, StatusBar, StyleSheet, Text, useWindowDimensions, View } from 'react-native'
import { StatusBar, StyleSheet, Text, useWindowDimensions, View } from 'react-native'
import FastImage from 'react-native-fast-image'
import TrackPlayer, { State, useProgress } from 'react-native-track-player'
import { currentQueueNameAtom, currentTrackAtom, playerStateAtom } from '../state/trackplayer'
import { queueNameAtom, currentTrackAtom, playerStateAtom, useNext, usePrevious } from '../state/trackplayer'
import colors from '../styles/colors'
import text, { Font } from '../styles/text'
import { formatDuration } from '../util'
import CoverArt from './common/CoverArt'
import ImageGradientBackground from './common/ImageGradientBackground'
import PressableImage from './common/PressableImage'
const NowPlayingHeader = () => {
const queueName = useAtomValue(currentQueueNameAtom)
const queueName = useAtomValue(queueNameAtom)
return (
<View style={headerStyles.container}>
@ -163,45 +164,52 @@ const seekStyles = StyleSheet.create({
const PlayerControls = () => {
const state = useAtomValue(playerStateAtom)
const next = useNext()
const previous = usePrevious()
let playPauseIcon: number
let playPauseStyle: any
let playPauseAction: () => void
let playPauseAction: undefined | (() => void)
let disabled: boolean
switch (state) {
case State.Playing:
case State.Buffering:
case State.Connecting:
disabled = false
playPauseIcon = require('../../res/pause_circle-fill.png')
playPauseStyle = controlsStyles.enabled
playPauseAction = () => TrackPlayer.pause()
break
case State.Paused:
disabled = false
playPauseIcon = require('../../res/play_circle-fill.png')
playPauseStyle = controlsStyles.enabled
playPauseAction = () => TrackPlayer.play()
break
default:
disabled = true
playPauseIcon = require('../../res/play_circle-fill.png')
playPauseStyle = controlsStyles.disabled
playPauseAction = () => {}
playPauseAction = undefined
break
}
return (
<View style={controlsStyles.container}>
<FastImage
<PressableImage
onPress={disabled ? undefined : previous}
source={require('../../res/previous-fill.png')}
tintColor="white"
style={{ ...controlsStyles.skip, ...playPauseStyle }}
style={controlsStyles.skip}
disabled={disabled}
/>
<Pressable onPress={playPauseAction}>
<FastImage source={playPauseIcon} tintColor="white" style={{ ...controlsStyles.play, ...playPauseStyle }} />
</Pressable>
<FastImage
<PressableImage
onPress={playPauseAction}
source={playPauseIcon}
style={controlsStyles.play}
disabled={disabled}
/>
<PressableImage
onPress={disabled ? undefined : next}
source={require('../../res/next-fill.png')}
tintColor="white"
style={{ ...controlsStyles.skip, ...playPauseStyle }}
style={controlsStyles.skip}
disabled={disabled}
/>
</View>
)
@ -224,12 +232,6 @@ const controlsStyles = StyleSheet.create({
height: 90,
width: 90,
},
enabled: {
opacity: 1,
},
disabled: {
opacity: 0.35,
},
})
const NowPlayingLayout = () => {

View File

@ -1,19 +1,47 @@
import React, { useCallback, useEffect } from 'react'
import TrackPlayer, { Event, State, useTrackPlayerEvents } from 'react-native-track-player'
import { useAppState } from '@react-native-community/hooks'
import { useUpdateAtom, useAtomValue } from 'jotai/utils'
import { currentQueueNameAtom, currentTrackAtom, playerStateAtom } from '../state/trackplayer'
import { useAtomValue, useUpdateAtom } from 'jotai/utils'
import React, { useEffect } from 'react'
import { View } from 'react-native'
import TrackPlayer, { Event, useTrackPlayerEvents } from 'react-native-track-player'
import { currentTrackAtom, getQueue, getTrack, playerStateAtom, queueWriteAtom } from '../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 <AppActiveResponder update={update} />
}
const CurrentTrackState = () => {
const setCurrentTrack = useUpdateAtom(currentTrackAtom)
const appState = useAppState()
const update = useCallback(async () => {
const update = async (payload?: Payload) => {
if (payload?.type === Event.PlaybackQueueEnded && 'track' in payload) {
setCurrentTrack(undefined)
return
}
const index = await TrackPlayer.getCurrentTrack()
if (index !== null && index >= 0) {
const track = await TrackPlayer.getTrack(index)
const track = await getTrack(index)
if (track !== null) {
setCurrentTrack(track)
return
@ -21,102 +49,52 @@ const CurrentTrackState = () => {
}
setCurrentTrack(undefined)
}, [setCurrentTrack])
}
useTrackPlayerEvents(
[
// Event.PlaybackState,
// Event.PlaybackTrackChanged,
Event.PlaybackQueueEnded,
Event.PlaybackMetadataReceived,
Event.RemoteDuck,
Event.RemoteNext,
Event.RemotePrevious,
Event.RemoteStop,
],
event => {
if (event.type === Event.PlaybackQueueEnded && 'track' in event) {
setCurrentTrack(undefined)
return
}
update()
},
return (
<TrackPlayerEventResponder
events={[
Event.PlaybackQueueEnded,
Event.PlaybackMetadataReceived,
Event.RemoteDuck,
Event.RemoteNext,
Event.RemotePrevious,
Event.RemoteStop,
]}
update={update}
/>
)
useEffect(() => {
if (appState === 'active') {
update()
}
}, [appState, update])
return <></>
}
const CurrentQueueName = () => {
const setCurrentQueueName = useUpdateAtom(currentQueueNameAtom)
const appState = useAppState()
const update = useCallback(async () => {
const queue = await TrackPlayer.getQueue()
if (queue !== null && queue.length > 0) {
setCurrentQueueName(queue[0].queueName)
return
}
setCurrentQueueName(undefined)
}, [setCurrentQueueName])
useTrackPlayerEvents(
[Event.PlaybackState, Event.PlaybackQueueEnded, Event.PlaybackMetadataReceived, Event.RemoteDuck, Event.RemoteStop],
event => {
if (event.type === Event.PlaybackState) {
if (event.state === State.Stopped || event.state === State.None) {
return
}
}
update()
},
)
useEffect(() => {
if (appState === 'active') {
update()
}
}, [appState, update])
return <></>
}
const PlayerState = () => {
const setPlayerState = useUpdateAtom(playerStateAtom)
const appState = useAppState()
const update = useCallback(
async (state?: State) => {
setPlayerState(state || (await TrackPlayer.getState()))
},
[setPlayerState],
)
const update = async (payload?: Payload) => {
setPlayerState(payload?.state || (await TrackPlayer.getState()))
}
useTrackPlayerEvents([Event.PlaybackState], event => {
update(event.state)
})
return <TrackPlayerEventResponder events={[Event.PlaybackState]} update={update} />
}
useEffect(() => {
if (appState === 'active') {
update()
const QueueState = () => {
const setQueue = useUpdateAtom(queueWriteAtom)
const update = async (payload?: Payload) => {
if (payload) {
setQueue([])
return
}
}, [appState, update])
setQueue(await getQueue())
}
return <></>
return <TrackPlayerEventResponder events={[Event.RemoteStop]} update={update} />
}
const Debug = () => {
const value = useAtomValue(currentQueueNameAtom)
const value = useAtomValue(queueWriteAtom)
useEffect(() => {
console.log(value)
console.log(value.map(t => t.title))
}, [value])
return <></>
@ -125,8 +103,8 @@ const Debug = () => {
const TrackPlayerState = () => (
<View>
<CurrentTrackState />
<CurrentQueueName />
<PlayerState />
<QueueState />
<Debug />
</View>
)

View File

@ -10,9 +10,8 @@ import {
useWindowDimensions,
View,
} from 'react-native'
import { useSetQueue } from '../../hooks/trackplayer'
import { albumAtomFamily } from '../../state/music'
import { currentTrackAtom } from '../../state/trackplayer'
import { currentTrackAtom, useSetQueue } from '../../state/trackplayer'
import colors from '../../styles/colors'
import text from '../../styles/text'
import AlbumArt from './AlbumArt'

View File

@ -0,0 +1,55 @@
import React, { useEffect, useState } from 'react'
import { GestureResponderEvent, LayoutRectangle, Pressable, ViewStyle } from 'react-native'
import FastImage, { Source } from 'react-native-fast-image'
const PressableImage: React.FC<{
source: Source | number
onPress?: (event: GestureResponderEvent) => void
style?: ViewStyle
tintColor?: string
disabled?: boolean
}> = ({ source, onPress, style, tintColor, disabled }) => {
const [opacity, setOpacity] = useState(1)
const [dimensions, setDimensions] = useState<LayoutRectangle | undefined>(undefined)
disabled = disabled === undefined ? false : disabled
style = {
...(style || {}),
opacity,
}
useEffect(() => {
disabled ? setOpacity(0.3) : setOpacity(1)
}, [disabled])
return (
<Pressable
style={style}
onPress={onPress}
disabled={disabled}
onPressIn={() => {
if (!disabled) {
setOpacity(0.4)
}
}}
onPressOut={() => {
if (!disabled) {
setOpacity(1)
}
}}
onLayout={event => setDimensions(event.nativeEvent.layout)}>
<FastImage
style={{
display: dimensions ? 'flex' : 'none',
height: dimensions?.height,
width: dimensions?.width,
}}
source={source}
tintColor={tintColor || 'white'}
resizeMode={FastImage.resizeMode.contain}
/>
</Pressable>
)
}
export default PressableImage

View File

@ -1,37 +1,164 @@
import { atom } from 'jotai'
import { State, Track } from 'react-native-track-player'
import TrackPlayer, { State, Track } from 'react-native-track-player'
import equal from 'fast-deep-equal'
import { useUpdateAtom } from 'jotai/utils'
import { Song } from '../models/music'
type OptionalTrack = Track | undefined
type TrackExt = Track & {
id: string
queueName: string
}
const currentTrack = atom<OptionalTrack>(undefined)
export const currentTrackAtom = atom<OptionalTrack, OptionalTrack>(
get => get(currentTrack),
(get, set, value) => {
if (!equal(get(currentTrack), value)) {
set(currentTrack, value)
}
},
)
type OptionalString = string | undefined
const currentQueueName = atom<OptionalString>(undefined)
export const currentQueueNameAtom = atom<OptionalString, OptionalString>(
get => get(currentQueueName),
(get, set, value) => {
if (get(currentQueueName) !== value) {
set(currentQueueName, value)
}
},
)
type OptionalTrackExt = TrackExt | undefined
const playerState = atom<State>(State.None)
export const playerStateAtom = atom<State, State>(
get => get(playerState),
(get, set, value) => {
if (get(playerState) !== value) {
set(playerState, value)
(get, set, update) => {
if (get(playerState) !== update) {
set(playerState, update)
}
},
)
const currentTrack = atom<OptionalTrackExt>(undefined)
export const currentTrackAtom = atom<OptionalTrackExt, OptionalTrackExt>(
get => get(currentTrack),
(get, set, update) => {
if (!equal(get(currentTrack), update)) {
set(currentTrack, update)
}
},
)
const _queue = atom<TrackExt[]>([])
export const queueAtom = atom<TrackExt[]>(get => get(_queue))
export const queueWriteAtom = atom<TrackExt[], TrackExt[]>(
get => get(_queue),
(get, set, update) => {
if (get(_queue) !== update) {
set(_queue, update)
}
},
)
export const queueNameAtom = atom<string | undefined>(get => {
const queue = get(_queue)
if (queue.length > 0) {
return queue[0].queueName
}
return undefined
})
export const getQueue = async (): Promise<TrackExt[]> => {
return ((await TrackPlayer.getQueue()) as TrackExt[]) || []
}
export const getTrack = async (index: number): Promise<TrackExt> => {
return ((await TrackPlayer.getTrack(index)) as TrackExt) || undefined
}
export const usePrevious = () => {
const setCurrentTrack = useUpdateAtom(currentTrackAtom)
return async () => {
try {
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()
} catch {}
}
}
export const useNext = () => {
const setCurrentTrack = useUpdateAtom(currentTrackAtom)
return async () => {
try {
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()
}
} catch {}
}
}
export const useAdd = () => {
const setQueue = useUpdateAtom(queueWriteAtom)
const setCurrentTrack = useUpdateAtom(currentTrackAtom)
return async (tracks: TrackExt | TrackExt[], insertBeforeindex?: number) => {
await TrackPlayer.add(tracks, insertBeforeindex)
const queue = await getQueue()
setQueue(queue)
setCurrentTrack(queue.length > 0 ? queue[await TrackPlayer.getCurrentTrack()] : undefined)
}
}
export const useReset = () => {
const setQueue = useUpdateAtom(queueWriteAtom)
const setCurrentTrack = useUpdateAtom(currentTrackAtom)
return async () => {
await TrackPlayer.reset()
setQueue([])
setCurrentTrack(undefined)
}
}
export const useSetQueue = () => {
const setCurrentTrack = useUpdateAtom(currentTrackAtom)
const setQueue = useUpdateAtom(queueWriteAtom)
return async (songs: Song[], name: string, playId?: string) => {
await TrackPlayer.reset()
const tracks = songs.map(s => mapSongToTrack(s, name))
if (playId) {
setCurrentTrack(tracks.find(t => t.id === playId))
}
if (!playId) {
await TrackPlayer.add(tracks)
} else if (playId === tracks[0].id) {
await TrackPlayer.add(tracks)
await TrackPlayer.play()
} else {
const playIndex = tracks.findIndex(t => t.id === playId)
const tracks1 = tracks.slice(0, playIndex)
const tracks2 = tracks.slice(playIndex)
await TrackPlayer.add(tracks2)
await TrackPlayer.play()
await TrackPlayer.add(tracks1, 0)
}
setQueue(await getQueue())
}
}
function mapSongToTrack(song: Song, queueName: string): TrackExt {
return {
id: song.id,
queueName,
title: song.title,
artist: song.artist || 'Unknown Artist',
url: song.streamUri,
artwork: song.coverArtUri,
artworkThumb: song.coverArtThumbUri,
duration: song.duration,
}
}