impl cache create/delete with server create/delete

also impl test connection
This commit is contained in:
austinried 2021-08-18 12:11:44 +09:00
parent 52223e6979
commit b7a05ca955
10 changed files with 288 additions and 145 deletions

View File

@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react' import React, { useCallback, useEffect, useState } from 'react'
import { LayoutRectangle, Pressable, PressableProps } from 'react-native' import { GestureResponderEvent, LayoutRectangle, Pressable, PressableProps, ViewStyle } from 'react-native'
type PressableOpacityProps = PressableProps & { type PressableOpacityProps = PressableProps & {
ripple?: boolean ripple?: boolean
@ -9,10 +9,11 @@ type PressableOpacityProps = PressableProps & {
const PressableOpacity: React.FC<PressableOpacityProps> = props => { const PressableOpacity: React.FC<PressableOpacityProps> = props => {
const [opacity, setOpacity] = useState(1) const [opacity, setOpacity] = useState(1)
const [disabledStyle, setDisabledStyle] = useState<ViewStyle>({})
const [dimensions, setDimensions] = useState<LayoutRectangle | undefined>(undefined) const [dimensions, setDimensions] = useState<LayoutRectangle | undefined>(undefined)
useEffect(() => { useEffect(() => {
props.disabled === true ? setOpacity(0.3) : setOpacity(1) props.disabled === true ? setDisabledStyle({ opacity: 0.3 }) : setDisabledStyle({})
}, [props.disabled]) }, [props.disabled])
props = { props = {
@ -20,10 +21,41 @@ const PressableOpacity: React.FC<PressableOpacityProps> = props => {
unstable_pressDelay: props.unstable_pressDelay === undefined ? 60 : props.unstable_pressDelay, unstable_pressDelay: props.unstable_pressDelay === undefined ? 60 : props.unstable_pressDelay,
} }
const onPressIn = useCallback<(event: GestureResponderEvent) => void>(
data => {
if (props.disabled) {
return
}
setOpacity(0.4)
props.onPressIn ? props.onPressIn(data) : null
},
[props],
)
const onPressOut = useCallback<(event: GestureResponderEvent) => void>(
data => {
if (props.disabled) {
return
}
setOpacity(1)
props.onPressOut ? props.onPressOut(data) : null
},
[props],
)
const onLongPress = useCallback<(event: GestureResponderEvent) => void>(
data => {
if (props.disabled) {
return
}
setOpacity(1)
props.onLongPress ? props.onLongPress(data) : null
},
[props],
)
return ( return (
<Pressable <Pressable
{...props} {...props}
style={[{ justifyContent: 'center', alignItems: 'center' }, props.style as any, { opacity }]} style={[{ justifyContent: 'center', alignItems: 'center' }, props.style as any, { opacity }, disabledStyle]}
android_ripple={ android_ripple={
props.ripple props.ripple
? { ? {
@ -34,22 +66,9 @@ const PressableOpacity: React.FC<PressableOpacityProps> = props => {
: undefined : undefined
} }
onLayout={event => setDimensions(event.nativeEvent.layout)} onLayout={event => setDimensions(event.nativeEvent.layout)}
onPressIn={() => { onPressIn={onPressIn}
if (!props.disabled) { onPressOut={onPressOut}
setOpacity(0.4) onLongPress={onLongPress}>
}
}}
onPressOut={() => {
if (!props.disabled) {
setOpacity(1)
}
}}
onLongPress={data => {
if (!props.disabled) {
setOpacity(1)
props.onLongPress ? props.onLongPress(data) : null
}
}}>
{props.children} {props.children}
</Pressable> </Pressable>
) )

View File

@ -42,7 +42,7 @@ const styles = StyleSheet.create({
itemSubtitle: { itemSubtitle: {
fontFamily: font.regular, fontFamily: font.regular,
color: colors.text.secondary, color: colors.text.secondary,
fontSize: 15, fontSize: 14,
}, },
}) })

View File

@ -72,9 +72,9 @@ export type HomeLists = { [key: string]: AlbumListItem[] }
export type StarrableItemType = 'song' | 'album' | 'artist' export type StarrableItemType = 'song' | 'album' | 'artist'
export enum CacheItemType { export enum CacheItemType {
coverArt, coverArt = 'coverArt',
artistArt, artistArt = 'artistArt',
song, song = 'song',
} }
export type CacheItemTypeKey = keyof typeof CacheItemType export type CacheItemTypeKey = keyof typeof CacheItemType

View File

@ -8,28 +8,27 @@ import font from '@app/styles/font'
import { useNavigation } from '@react-navigation/native' import { useNavigation } from '@react-navigation/native'
import md5 from 'md5' import md5 from 'md5'
import React, { useCallback, useState } from 'react' import React, { useCallback, useState } from 'react'
import { StyleSheet, Text, TextInput, View } from 'react-native' import { StyleSheet, Text, TextInput, ToastAndroid, View } from 'react-native'
import { v4 as uuidv4 } from 'uuid' import { v4 as uuidv4 } from 'uuid'
function replaceIndex<T>(array: T[], index: number, replacement: T): T[] {
const start = array.slice(0, index)
const end = array.slice(index + 1)
return [...start, replacement, ...end]
}
const ServerView: React.FC<{ const ServerView: React.FC<{
id?: string id?: string
}> = ({ id }) => { }> = ({ id }) => {
const navigation = useNavigation() const navigation = useNavigation()
const activeServer = useStore(selectSettings.activeServer) const activeServer = useStore(selectSettings.activeServer)
const setActiveServer = useStore(selectSettings.setActiveServer)
const servers = useStore(selectSettings.servers) const servers = useStore(selectSettings.servers)
const setServers = useStore(selectSettings.setServers) const addServer = useStore(selectSettings.addServer)
const updateServer = useStore(selectSettings.updateServer)
const removeServer = useStore(selectSettings.removeServer)
const server = id ? servers.find(s => s.id === id) : undefined const server = id ? servers.find(s => s.id === id) : undefined
const pingServer = useStore(selectSettings.pingServer)
const [address, setAddress] = useState(server?.address || '') const [address, setAddress] = useState(server?.address || '')
const [username, setUsername] = useState(server?.username || '') const [username, setUsername] = useState(server?.username || '')
const [password, setPassword] = useState(server?.token ? 'password' : '') const [password, setPassword] = useState(server?.token ? 'password' : '')
const [testing, setTesting] = useState(false)
const [removing, setRemoving] = useState(false)
const [saving, setSaving] = useState(false)
const validate = useCallback(() => { const validate = useCallback(() => {
return !!address && !!username && !!password return !!address && !!username && !!password
@ -47,11 +46,7 @@ const ServerView: React.FC<{
} }
}, [navigation]) }, [navigation])
const save = useCallback(() => { const createServer = useCallback<() => Server>(() => {
if (!validate()) {
return
}
const salt = server?.salt || uuidv4() const salt = server?.salt || uuidv4()
let token: string let token: string
if (password === 'password' && server?.token) { if (password === 'password' && server?.token) {
@ -60,47 +55,76 @@ const ServerView: React.FC<{
token = md5(password + salt) token = md5(password + salt)
} }
const update: Server = { return {
id: server?.id || uuidv4(), id: server?.id || uuidv4(),
address, address,
username, username,
salt, salt,
token, token,
} }
}, [address, password, server?.id, server?.salt, server?.token, username])
if (server) { const save = useCallback(() => {
setServers( if (!validate()) {
replaceIndex( return
servers,
servers.findIndex(s => s.id === id),
update,
),
)
} else {
setServers([...servers, update])
} }
if (!activeServer) { setSaving(true)
setActiveServer(update.id) const update = createServer()
}
exit() const waitForSave = async () => {
}, [activeServer, address, exit, id, password, server, servers, setActiveServer, setServers, username, validate]) try {
if (id) {
updateServer(update)
} else {
await addServer(update)
}
exit()
} catch (err) {
console.error(err)
setSaving(false)
}
}
waitForSave()
}, [addServer, createServer, exit, id, updateServer, validate])
const remove = useCallback(() => { const remove = useCallback(() => {
if (!canRemove()) { if (!canRemove()) {
return return
} }
const update = [...servers] setRemoving(true)
update.splice( const waitForRemove = async () => {
update.findIndex(s => s.id === id), try {
1, await removeServer(id as string)
) exit()
} catch (err) {
console.error(err)
setRemoving(false)
}
}
waitForRemove()
}, [canRemove, exit, id, removeServer])
setServers(update) const test = useCallback(() => {
exit() setTesting(true)
}, [canRemove, exit, id, servers, setServers]) const potential = createServer()
const ping = async () => {
const res = await pingServer(potential)
if (res) {
ToastAndroid.show(`Connection to ${potential.address} OK!`, ToastAndroid.SHORT)
} else {
ToastAndroid.show(`Connection to ${potential.address} failed, check settings or server`, ToastAndroid.SHORT)
}
setTesting(false)
}
ping()
}, [createServer, pingServer, setTesting])
const disableControls = useCallback(() => {
return !validate() || testing || removing || saving
}, [validate, testing, removing, saving])
return ( return (
<GradientScrollView style={styles.scroll} contentContainerStyle={styles.scrollContentContainer}> <GradientScrollView style={styles.scroll} contentContainerStyle={styles.scrollContentContainer}>
@ -139,18 +163,19 @@ const ServerView: React.FC<{
onChangeText={setPassword} onChangeText={setPassword}
/> />
<Button <Button
disabled={!validate()} disabled={disableControls()}
style={styles.button} style={styles.button}
title="Test Connection" title="Test Connection"
buttonStyle="hollow" buttonStyle="hollow"
onPress={() => {}} onPress={test}
/> />
<Button <Button
disabled={disableControls()}
style={[styles.button, styles.delete, { display: canRemove() ? 'flex' : 'none' }]} style={[styles.button, styles.delete, { display: canRemove() ? 'flex' : 'none' }]}
title="Delete" title="Delete"
onPress={remove} onPress={remove}
/> />
<Button disabled={!validate()} style={styles.button} title="Save" onPress={save} /> <Button disabled={disableControls()} style={styles.button} title="Save" onPress={save} />
</View> </View>
</GradientScrollView> </GradientScrollView>
) )

View File

@ -1,23 +1,7 @@
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { Text, View } from 'react-native' import { Text, View } from 'react-native'
import RNFS from 'react-native-fs'
import paths from '@app/util/paths'
import { Store, useStore } from '@app/state/store' import { Store, useStore } from '@app/state/store'
async function mkdir(path: string): Promise<void> {
const exists = await RNFS.exists(path)
if (exists) {
const isDir = (await RNFS.stat(path)).isDirectory()
if (!isDir) {
throw new Error(`path exists and is not a directory: ${path}`)
} else {
return
}
}
return await RNFS.mkdir(path)
}
const selectHydrated = (store: Store) => store.hydrated const selectHydrated = (store: Store) => store.hydrated
const SplashPage: React.FC<{}> = ({ children }) => { const SplashPage: React.FC<{}> = ({ children }) => {
@ -27,9 +11,7 @@ const SplashPage: React.FC<{}> = ({ children }) => {
const minSplashTime = new Promise(resolve => setTimeout(resolve, 1)) const minSplashTime = new Promise(resolve => setTimeout(resolve, 1))
const prepare = async () => { const prepare = async () => {
await mkdir(paths.imageCache) return
await mkdir(paths.songCache)
await mkdir(paths.songs)
} }
const promise = Promise.all([prepare(), minSplashTime]) const promise = Promise.all([prepare(), minSplashTime])

View File

@ -1,4 +1,5 @@
import { CacheFile, CacheItemTypeKey, CacheRequest } from '@app/models/music' import { CacheFile, CacheItemType, CacheItemTypeKey, CacheRequest } from '@app/models/music'
import { mkdir, rmdir } from '@app/util/fs'
import PromiseQueue from '@app/util/PromiseQueue' import PromiseQueue from '@app/util/PromiseQueue'
import produce from 'immer' import produce from 'immer'
import RNFS from 'react-native-fs' import RNFS from 'react-native-fs'
@ -33,6 +34,11 @@ export type CacheSlice = {
cacheRequests: CacheRequestsByServer cacheRequests: CacheRequestsByServer
fetchCoverArtFilePath: (coverArt: string) => Promise<string | undefined> fetchCoverArtFilePath: (coverArt: string) => Promise<string | undefined>
createCache: (serverId: string) => Promise<void>
prepareCache: (serverId: string) => void
pendingRemoval: Record<string, boolean>
removeCache: (serverId: string) => Promise<void>
} }
export const selectCache = { export const selectCache = {
@ -66,6 +72,10 @@ export const createCacheSlice = (set: SetState<Store>, get: GetState<Store>): Ca
return return
} }
if (get().pendingRemoval[activeServerId]) {
return
}
const inProgress = get().cacheRequests[activeServerId][key][itemId] const inProgress = get().cacheRequests[activeServerId][key][itemId]
if (inProgress && inProgress.promise !== undefined) { if (inProgress && inProgress.promise !== undefined) {
return await inProgress.promise return await inProgress.promise
@ -154,4 +164,82 @@ export const createCacheSlice = (set: SetState<Store>, get: GetState<Store>): Ca
await get().cacheItem('coverArt', coverArt, () => client.getCoverArtUri({ id: coverArt })) await get().cacheItem('coverArt', coverArt, () => client.getCoverArtUri({ id: coverArt }))
return `file://${get().cacheFiles[activeServerId].coverArt[coverArt].path}` return `file://${get().cacheFiles[activeServerId].coverArt[coverArt].path}`
}, },
createCache: async serverId => {
for (const type in CacheItemType) {
await mkdir(`${RNFS.DocumentDirectoryPath}/servers/${serverId}/${type}`)
}
set(
produce<CacheSlice>(state => {
state.cacheFiles[serverId] = {
song: {},
coverArt: {},
artistArt: {},
}
}),
)
get().prepareCache(serverId)
},
prepareCache: serverId => {
set(
produce<CacheSlice>(state => {
if (!state.cacheDirs[serverId]) {
state.cacheDirs[serverId] = {
song: `${RNFS.DocumentDirectoryPath}/servers/${serverId}/song`,
coverArt: `${RNFS.DocumentDirectoryPath}/servers/${serverId}/coverArt`,
artistArt: `${RNFS.DocumentDirectoryPath}/servers/${serverId}/artistArt`,
}
}
if (!state.cacheRequests[serverId]) {
state.cacheRequests[serverId] = {
song: {},
coverArt: {},
artistArt: {},
}
}
}),
)
},
pendingRemoval: {},
removeCache: async serverId => {
set(
produce<CacheSlice>(state => {
state.pendingRemoval[serverId] = true
}),
)
const cacheRequests = get().cacheRequests[serverId]
const pendingRequests: Promise<void>[] = []
for (const type in CacheItemType) {
const requests = Object.values(cacheRequests[type as CacheItemTypeKey])
.filter(r => r.promise !== undefined)
.map(r => r.promise) as Promise<void>[]
pendingRequests.push(...requests)
}
await Promise.all(pendingRequests)
await rmdir(`${RNFS.DocumentDirectoryPath}/servers/${serverId}`)
set(
produce<CacheSlice>(state => {
delete state.pendingRemoval[serverId]
if (state.cacheDirs[serverId]) {
delete state.cacheDirs[serverId]
}
if (state.cacheFiles[serverId]) {
delete state.cacheFiles[serverId]
}
if (state.cacheRequests[serverId]) {
delete state.cacheRequests[serverId]
}
}),
)
},
}) })

View File

@ -1,37 +1,25 @@
import { CacheItemType } from '@app/models/music'
import { AppSettings, Server } from '@app/models/settings' import { AppSettings, Server } from '@app/models/settings'
import { Store } from '@app/state/store' import { Store } from '@app/state/store'
import { SubsonicApiClient } from '@app/subsonic/api' import { SubsonicApiClient } from '@app/subsonic/api'
import produce from 'immer' import produce from 'immer'
import RNFS from 'react-native-fs'
import { GetState, SetState } from 'zustand' import { GetState, SetState } from 'zustand'
async function mkdir(path: string): Promise<void> {
const exists = await RNFS.exists(path)
if (exists) {
const isDir = (await RNFS.stat(path)).isDirectory()
if (!isDir) {
throw new Error(`path exists and is not a directory: ${path}`)
} else {
return
}
}
return await RNFS.mkdir(path)
}
export type SettingsSlice = { export type SettingsSlice = {
settings: AppSettings settings: AppSettings
client?: SubsonicApiClient client?: SubsonicApiClient
setActiveServer: (id: string | undefined, force?: boolean) => Promise<void> setActiveServer: (id: string | undefined, force?: boolean) => Promise<void>
getActiveServer: () => Server | undefined getActiveServer: () => Server | undefined
setServers: (servers: Server[]) => void addServer: (server: Server) => Promise<void>
removeServer: (id: string) => Promise<void>
updateServer: (server: Server) => void
setScrobble: (scrobble: boolean) => void setScrobble: (scrobble: boolean) => void
setEstimateContentLength: (estimateContentLength: boolean) => void setEstimateContentLength: (estimateContentLength: boolean) => void
setMaxBitrateWifi: (maxBitrateWifi: number) => void setMaxBitrateWifi: (maxBitrateWifi: number) => void
setMaxBitrateMobile: (maxBitrateMobile: number) => void setMaxBitrateMobile: (maxBitrateMobile: number) => void
pingServer: (server?: Server) => Promise<boolean>
} }
export const selectSettings = { export const selectSettings = {
@ -41,7 +29,9 @@ export const selectSettings = {
setActiveServer: (state: SettingsSlice) => state.setActiveServer, setActiveServer: (state: SettingsSlice) => state.setActiveServer,
servers: (state: SettingsSlice) => state.settings.servers, servers: (state: SettingsSlice) => state.settings.servers,
setServers: (state: SettingsSlice) => state.setServers, addServer: (state: SettingsSlice) => state.addServer,
removeServer: (state: SettingsSlice) => state.removeServer,
updateServer: (state: SettingsSlice) => state.updateServer,
homeLists: (state: SettingsSlice) => state.settings.home.lists, homeLists: (state: SettingsSlice) => state.settings.home.lists,
@ -55,6 +45,8 @@ export const selectSettings = {
setMaxBitrateWifi: (state: SettingsSlice) => state.setMaxBitrateWifi, setMaxBitrateWifi: (state: SettingsSlice) => state.setMaxBitrateWifi,
maxBitrateMobile: (state: SettingsSlice) => state.settings.maxBitrateMobile, maxBitrateMobile: (state: SettingsSlice) => state.settings.maxBitrateMobile,
setMaxBitrateMobile: (state: SettingsSlice) => state.setMaxBitrateMobile, setMaxBitrateMobile: (state: SettingsSlice) => state.setMaxBitrateMobile,
pingServer: (state: SettingsSlice) => state.pingServer,
} }
export const createSettingsSlice = (set: SetState<Store>, get: GetState<Store>): SettingsSlice => ({ export const createSettingsSlice = (set: SetState<Store>, get: GetState<Store>): SettingsSlice => ({
@ -84,50 +76,55 @@ export const createSettingsSlice = (set: SetState<Store>, get: GetState<Store>):
return return
} }
for (const type in CacheItemType) { get().prepareCache(newActiveServer.id)
await mkdir(`${RNFS.DocumentDirectoryPath}/servers/${id}/${type}`)
}
set( set(
produce<Store>(state => { produce<Store>(state => {
state.settings.activeServer = newActiveServer.id state.settings.activeServer = newActiveServer.id
state.client = new SubsonicApiClient(newActiveServer) state.client = new SubsonicApiClient(newActiveServer)
if (!state.cacheDirs[newActiveServer.id]) {
state.cacheDirs[newActiveServer.id] = {
song: `${RNFS.DocumentDirectoryPath}/servers/${id}/song`,
coverArt: `${RNFS.DocumentDirectoryPath}/servers/${id}/coverArt`,
artistArt: `${RNFS.DocumentDirectoryPath}/servers/${id}/artistArt`,
}
}
if (!state.cacheFiles[newActiveServer.id]) {
state.cacheFiles[newActiveServer.id] = {
song: {},
coverArt: {},
artistArt: {},
}
}
if (!state.cacheRequests[newActiveServer.id]) {
state.cacheRequests[newActiveServer.id] = {
song: {},
coverArt: {},
artistArt: {},
}
}
}), }),
) )
}, },
getActiveServer: () => get().settings.servers.find(s => s.id === get().settings.activeServer), getActiveServer: () => get().settings.servers.find(s => s.id === get().settings.activeServer),
setServers: servers => { addServer: async server => {
await get().createCache(server.id)
set( set(
produce<SettingsSlice>(state => { produce<SettingsSlice>(state => {
state.settings.servers = servers state.settings.servers.push(server)
}), }),
) )
const activeServer = servers.find(s => s.id === get().settings.activeServer)
get().setActiveServer(activeServer?.id) if (get().settings.servers.length === 1) {
get().setActiveServer(server.id)
}
},
removeServer: async id => {
await get().removeCache(id)
set(
produce<SettingsSlice>(state => {
state.settings.servers = state.settings.servers.filter(s => s.id !== id)
}),
)
},
updateServer: server => {
set(
produce<SettingsSlice>(state => {
state.settings.servers = replaceIndex(
state.settings.servers,
state.settings.servers.findIndex(s => s.id === server.id),
server,
)
}),
)
if (get().settings.activeServer === server.id) {
get().setActiveServer(server.id)
}
}, },
setScrobble: scrobble => { setScrobble: scrobble => {
@ -168,4 +165,30 @@ export const createSettingsSlice = (set: SetState<Store>, get: GetState<Store>):
get().rebuildQueue() get().rebuildQueue()
} }
}, },
pingServer: async server => {
let client: SubsonicApiClient
if (server) {
client = new SubsonicApiClient(server)
} else {
const currentClient = get().client
if (!currentClient) {
return false
}
client = currentClient
}
try {
await client.ping()
return true
} catch {
return false
}
},
}) })
function replaceIndex<T>(array: T[], index: number, replacement: T): T[] {
const start = array.slice(0, index)
const end = array.slice(index + 1)
return [...start, replacement, ...end]
}

View File

@ -35,7 +35,6 @@ import {
SubsonicResponse, SubsonicResponse,
} from '@app/subsonic/responses' } from '@app/subsonic/responses'
import { Server } from '@app/models/settings' import { Server } from '@app/models/settings'
import paths from '@app/util/paths'
import PromiseQueue from '@app/util/PromiseQueue' import PromiseQueue from '@app/util/PromiseQueue'
export class SubsonicApiError extends Error { export class SubsonicApiError extends Error {
@ -212,11 +211,6 @@ export class SubsonicApiClient {
// Media retrieval // Media retrieval
// //
async getCoverArt(params: GetCoverArtParams): Promise<string> {
const path = `${paths.songCache}/${params.id}`
return await this.apiDownload('getCoverArt', path, params)
}
getCoverArtUri(params?: GetCoverArtParams): string { getCoverArtUri(params?: GetCoverArtParams): string {
return this.buildUrl('getCoverArt', params) return this.buildUrl('getCoverArt', params)
} }

19
app/util/fs.ts Normal file
View File

@ -0,0 +1,19 @@
import RNFS from 'react-native-fs'
export async function mkdir(path: string): Promise<void> {
const exists = await RNFS.exists(path)
if (exists) {
const isDir = (await RNFS.stat(path)).isDirectory()
if (!isDir) {
throw new Error(`path exists and is not a directory: ${path}`)
} else {
return
}
}
return await RNFS.mkdir(path)
}
export async function rmdir(path: string): Promise<void> {
return RNFS.unlink(path)
}

View File

@ -1,7 +0,0 @@
import RNFS from 'react-native-fs'
export default {
imageCache: `${RNFS.DocumentDirectoryPath}/image_cache`,
songCache: `${RNFS.DocumentDirectoryPath}/song_cache`,
songs: `${RNFS.DocumentDirectoryPath}/songs`,
}