mirror of
https://github.com/austinried/subtracks.git
synced 2026-02-10 15:02:42 +01:00
reorg again, absolute (module) imports
This commit is contained in:
27
app/App.tsx
Normal file
27
app/App.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React from 'react'
|
||||
import { DarkTheme, NavigationContainer } from '@react-navigation/native'
|
||||
import SplashPage from '@app/screens/SplashPage'
|
||||
import RootNavigator from '@app/navigation/RootNavigator'
|
||||
import { Provider } from 'jotai'
|
||||
import { StatusBar, View } from 'react-native'
|
||||
import colors from '@app/styles/colors'
|
||||
import TrackPlayerState from '@app/components/TrackPlayerState'
|
||||
|
||||
const theme = { ...DarkTheme }
|
||||
theme.colors.background = colors.gradient.high
|
||||
|
||||
const App = () => (
|
||||
<Provider>
|
||||
<StatusBar animated={true} backgroundColor={'rgba(0, 0, 0, 0.4)'} barStyle={'light-content'} translucent={true} />
|
||||
<TrackPlayerState />
|
||||
<View style={{ flex: 1, backgroundColor: colors.gradient.high }}>
|
||||
<SplashPage>
|
||||
<NavigationContainer theme={theme}>
|
||||
<RootNavigator />
|
||||
</NavigationContainer>
|
||||
</SplashPage>
|
||||
</View>
|
||||
</Provider>
|
||||
)
|
||||
|
||||
export default App
|
||||
4
app/app.json
Normal file
4
app/app.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "SubSonify",
|
||||
"displayName": "SubSonify"
|
||||
}
|
||||
57
app/components/AlbumArt.tsx
Normal file
57
app/components/AlbumArt.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useAtomValue } from 'jotai/utils'
|
||||
import React from 'react'
|
||||
import { ActivityIndicator, View } from 'react-native'
|
||||
import FastImage from 'react-native-fast-image'
|
||||
import LinearGradient from 'react-native-linear-gradient'
|
||||
import { albumArtAtomFamily } from '@app/state/music'
|
||||
import colors from '@app/styles/colors'
|
||||
import CoverArt from '@app/components/CoverArt'
|
||||
|
||||
interface AlbumArtProps {
|
||||
id: string
|
||||
height: number
|
||||
width: number
|
||||
}
|
||||
|
||||
const AlbumArt: React.FC<AlbumArtProps> = ({ id, height, width }) => {
|
||||
const albumArt = useAtomValue(albumArtAtomFamily(id))
|
||||
|
||||
const Placeholder = () => (
|
||||
<LinearGradient colors={[colors.accent, colors.accentLow]}>
|
||||
<FastImage
|
||||
source={require('../../res/record.png')}
|
||||
style={{ height, width }}
|
||||
resizeMode={FastImage.resizeMode.contain}
|
||||
/>
|
||||
</LinearGradient>
|
||||
)
|
||||
|
||||
return (
|
||||
<CoverArt
|
||||
PlaceholderComponent={Placeholder}
|
||||
height={height}
|
||||
width={width}
|
||||
coverArtUri={width > 128 ? albumArt?.uri : albumArt?.thumbUri}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const AlbumArtFallback: React.FC<AlbumArtProps> = ({ height, width }) => (
|
||||
<View
|
||||
style={{
|
||||
height,
|
||||
width,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
<ActivityIndicator size="small" color={colors.accent} />
|
||||
</View>
|
||||
)
|
||||
|
||||
const AlbumArtLoader: React.FC<AlbumArtProps> = props => (
|
||||
<React.Suspense fallback={<AlbumArtFallback {...props} />}>
|
||||
<AlbumArt {...props} />
|
||||
</React.Suspense>
|
||||
)
|
||||
|
||||
export default React.memo(AlbumArtLoader)
|
||||
201
app/components/ArtistArt.tsx
Normal file
201
app/components/ArtistArt.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import { useAtomValue } from 'jotai/utils'
|
||||
import React from 'react'
|
||||
import { ActivityIndicator, View } from 'react-native'
|
||||
import FastImage from 'react-native-fast-image'
|
||||
import LinearGradient from 'react-native-linear-gradient'
|
||||
import { artistArtAtomFamily } from '@app/state/music'
|
||||
import colors from '@app/styles/colors'
|
||||
import CoverArt from '@app/components/CoverArt'
|
||||
|
||||
interface ArtistArtSizeProps {
|
||||
height: number
|
||||
width: number
|
||||
}
|
||||
|
||||
interface ArtistArtXUpProps extends ArtistArtSizeProps {
|
||||
coverArtUris: string[]
|
||||
}
|
||||
|
||||
interface ArtistArtProps extends ArtistArtSizeProps {
|
||||
id: string
|
||||
}
|
||||
|
||||
const PlaceholderContainer: React.FC<ArtistArtSizeProps> = ({ height, width, children }) => (
|
||||
<LinearGradient
|
||||
colors={[colors.accent, colors.accentLow]}
|
||||
style={{
|
||||
height,
|
||||
width,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
{children}
|
||||
</LinearGradient>
|
||||
)
|
||||
|
||||
const FourUp: React.FC<ArtistArtXUpProps> = ({ height, width, coverArtUris }) => {
|
||||
const halfHeight = height / 2
|
||||
const halfWidth = width / 2
|
||||
|
||||
return (
|
||||
<PlaceholderContainer height={height} width={width}>
|
||||
<View style={{ width, height: halfHeight, flexDirection: 'row' }}>
|
||||
<FastImage
|
||||
source={{ uri: coverArtUris[0] }}
|
||||
style={{ height: halfHeight, width: halfWidth }}
|
||||
resizeMode={FastImage.resizeMode.cover}
|
||||
/>
|
||||
<FastImage
|
||||
source={{ uri: coverArtUris[1] }}
|
||||
style={{ height: halfHeight, width: halfWidth }}
|
||||
resizeMode={FastImage.resizeMode.cover}
|
||||
/>
|
||||
</View>
|
||||
<View style={{ width, height: halfHeight, flexDirection: 'row' }}>
|
||||
<FastImage
|
||||
source={{ uri: coverArtUris[2] }}
|
||||
style={{ height: halfHeight, width: halfWidth }}
|
||||
resizeMode={FastImage.resizeMode.cover}
|
||||
/>
|
||||
<FastImage
|
||||
source={{ uri: coverArtUris[3] }}
|
||||
style={{ height: halfHeight, width: halfWidth }}
|
||||
resizeMode={FastImage.resizeMode.cover}
|
||||
/>
|
||||
</View>
|
||||
</PlaceholderContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const ThreeUp: React.FC<ArtistArtXUpProps> = ({ height, width, coverArtUris }) => {
|
||||
const halfHeight = height / 2
|
||||
const halfWidth = width / 2
|
||||
|
||||
return (
|
||||
<PlaceholderContainer height={height} width={width}>
|
||||
<View style={{ width, height: halfHeight, flexDirection: 'row' }}>
|
||||
<FastImage
|
||||
source={{ uri: coverArtUris[0] }}
|
||||
style={{ height: halfHeight, width }}
|
||||
resizeMode={FastImage.resizeMode.cover}
|
||||
/>
|
||||
</View>
|
||||
<View style={{ width, height: halfHeight, flexDirection: 'row' }}>
|
||||
<FastImage
|
||||
source={{ uri: coverArtUris[1] }}
|
||||
style={{ height: halfHeight, width: halfWidth }}
|
||||
resizeMode={FastImage.resizeMode.cover}
|
||||
/>
|
||||
<FastImage
|
||||
source={{ uri: coverArtUris[2] }}
|
||||
style={{ height: halfHeight, width: halfWidth }}
|
||||
resizeMode={FastImage.resizeMode.cover}
|
||||
/>
|
||||
</View>
|
||||
</PlaceholderContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const TwoUp: React.FC<ArtistArtXUpProps> = ({ height, width, coverArtUris }) => {
|
||||
const halfHeight = height / 2
|
||||
|
||||
return (
|
||||
<PlaceholderContainer height={height} width={width}>
|
||||
<View style={{ width, height: halfHeight, flexDirection: 'row' }}>
|
||||
<FastImage
|
||||
source={{ uri: coverArtUris[0] }}
|
||||
style={{ height: halfHeight, width }}
|
||||
resizeMode={FastImage.resizeMode.cover}
|
||||
/>
|
||||
</View>
|
||||
<View style={{ width, height: halfHeight, flexDirection: 'row' }}>
|
||||
<FastImage
|
||||
source={{ uri: coverArtUris[1] }}
|
||||
style={{ height: halfHeight, width }}
|
||||
resizeMode={FastImage.resizeMode.cover}
|
||||
/>
|
||||
</View>
|
||||
</PlaceholderContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const OneUp: React.FC<ArtistArtXUpProps> = ({ height, width, coverArtUris }) => {
|
||||
return (
|
||||
<PlaceholderContainer height={height} width={width}>
|
||||
<FastImage source={{ uri: coverArtUris[0] }} style={{ height, width }} resizeMode={FastImage.resizeMode.cover} />
|
||||
</PlaceholderContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const NoneUp: React.FC<ArtistArtSizeProps> = ({ height, width }) => {
|
||||
return (
|
||||
<PlaceholderContainer height={height} width={width}>
|
||||
<FastImage
|
||||
source={require('../../res/mic_on-fill.png')}
|
||||
style={{
|
||||
height: height - height / 4,
|
||||
width: width - width / 4,
|
||||
}}
|
||||
resizeMode={FastImage.resizeMode.cover}
|
||||
/>
|
||||
</PlaceholderContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const ArtistArt: React.FC<ArtistArtProps> = ({ id, height, width }) => {
|
||||
const artistArt = useAtomValue(artistArtAtomFamily(id))
|
||||
|
||||
const Placeholder = () => {
|
||||
const none = <NoneUp height={height} width={width} />
|
||||
|
||||
if (!artistArt || !artistArt.coverArtUris) {
|
||||
return none
|
||||
}
|
||||
const { coverArtUris } = artistArt
|
||||
|
||||
if (coverArtUris.length >= 4) {
|
||||
return <FourUp height={height} width={width} coverArtUris={coverArtUris} />
|
||||
}
|
||||
if (coverArtUris.length === 3) {
|
||||
return <ThreeUp height={height} width={width} coverArtUris={coverArtUris} />
|
||||
}
|
||||
if (coverArtUris.length === 2) {
|
||||
return <TwoUp height={height} width={width} coverArtUris={coverArtUris} />
|
||||
}
|
||||
if (coverArtUris.length === 1) {
|
||||
return <OneUp height={height} width={width} coverArtUris={coverArtUris} />
|
||||
}
|
||||
|
||||
return none
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
borderRadius: height / 2,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<CoverArt PlaceholderComponent={Placeholder} height={height} width={width} coverArtUri={artistArt?.uri} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const ArtistArtFallback: React.FC<ArtistArtProps> = ({ height, width }) => (
|
||||
<View
|
||||
style={{
|
||||
height,
|
||||
width,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
<ActivityIndicator size="small" color={colors.accent} />
|
||||
</View>
|
||||
)
|
||||
|
||||
const ArtistArtLoader: React.FC<ArtistArtProps> = props => (
|
||||
<React.Suspense fallback={<ArtistArtFallback {...props} />}>
|
||||
<ArtistArt {...props} />
|
||||
</React.Suspense>
|
||||
)
|
||||
|
||||
export default React.memo(ArtistArtLoader)
|
||||
31
app/components/Button.tsx
Normal file
31
app/components/Button.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import React, { useState } from 'react'
|
||||
import { GestureResponderEvent, Pressable, Text } from 'react-native'
|
||||
import colors from '@app/styles/colors'
|
||||
import text from '@app/styles/text'
|
||||
|
||||
const Button: React.FC<{
|
||||
title: string
|
||||
onPress: (event: GestureResponderEvent) => void
|
||||
}> = ({ title, onPress }) => {
|
||||
const [opacity, setOpacity] = useState(1)
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onPressIn={() => setOpacity(0.6)}
|
||||
onPressOut={() => setOpacity(1)}
|
||||
onLongPress={() => setOpacity(1)}
|
||||
style={{
|
||||
backgroundColor: colors.accent,
|
||||
paddingHorizontal: 24,
|
||||
minHeight: 42,
|
||||
justifyContent: 'center',
|
||||
borderRadius: 1000,
|
||||
opacity,
|
||||
}}>
|
||||
<Text style={{ ...text.button }}>{title}</Text>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
export default Button
|
||||
63
app/components/CoverArt.tsx
Normal file
63
app/components/CoverArt.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { ActivityIndicator, StyleSheet, View } from 'react-native'
|
||||
import FastImage from 'react-native-fast-image'
|
||||
import colors from '@app/styles/colors'
|
||||
|
||||
const CoverArt: React.FC<{
|
||||
PlaceholderComponent: () => JSX.Element
|
||||
height?: string | number
|
||||
width?: string | number
|
||||
coverArtUri?: string
|
||||
}> = ({ PlaceholderComponent, height, width, coverArtUri }) => {
|
||||
const [placeholderVisible, setPlaceholderVisible] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (!coverArtUri) {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [coverArtUri, setLoading])
|
||||
|
||||
const Image = () => (
|
||||
<FastImage
|
||||
source={{ uri: coverArtUri, priority: 'high' }}
|
||||
style={{ ...styles.image, opacity: placeholderVisible ? 0 : 1 }}
|
||||
resizeMode={FastImage.resizeMode.contain}
|
||||
onError={() => {
|
||||
setLoading(false)
|
||||
setPlaceholderVisible(true)
|
||||
}}
|
||||
onLoadEnd={() => setLoading(false)}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={{ ...styles.container, height, width }}>
|
||||
{coverArtUri ? <Image /> : <></>}
|
||||
<View style={{ ...styles.placeholderContainer, opacity: placeholderVisible ? 1 : 0 }}>
|
||||
<PlaceholderComponent />
|
||||
</View>
|
||||
<ActivityIndicator style={styles.indicator} animating={loading} size={'large'} color={colors.accent} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {},
|
||||
image: {
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
},
|
||||
placeholderContainer: {
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
position: 'absolute',
|
||||
},
|
||||
indicator: {
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
position: 'absolute',
|
||||
},
|
||||
})
|
||||
|
||||
export default React.memo(CoverArt)
|
||||
31
app/components/GradientBackground.tsx
Normal file
31
app/components/GradientBackground.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import React from 'react'
|
||||
import { useWindowDimensions, ViewStyle } from 'react-native'
|
||||
import LinearGradient from 'react-native-linear-gradient'
|
||||
import colorStyles from '@app/styles/colors'
|
||||
|
||||
const GradientBackground: React.FC<{
|
||||
height?: number | string
|
||||
width?: number | string
|
||||
position?: 'relative' | 'absolute'
|
||||
style?: ViewStyle
|
||||
colors?: string[]
|
||||
locations?: number[]
|
||||
}> = ({ height, width, position, style, colors, locations, children }) => {
|
||||
const layout = useWindowDimensions()
|
||||
|
||||
return (
|
||||
<LinearGradient
|
||||
colors={colors || [colorStyles.gradient.high, colorStyles.gradient.low]}
|
||||
locations={locations || [0.01, 0.7]}
|
||||
style={{
|
||||
...style,
|
||||
width: width || '100%',
|
||||
height: height || layout.height,
|
||||
position: position || 'absolute',
|
||||
}}>
|
||||
{children}
|
||||
</LinearGradient>
|
||||
)
|
||||
}
|
||||
|
||||
export default GradientBackground
|
||||
24
app/components/GradientFlatList.tsx
Normal file
24
app/components/GradientFlatList.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from 'react'
|
||||
import { FlatList, FlatListProps, useWindowDimensions } from 'react-native'
|
||||
import colors from '@app/styles/colors'
|
||||
import GradientBackground from '@app/components/GradientBackground'
|
||||
|
||||
function GradientFlatList<ItemT>(props: FlatListProps<ItemT>) {
|
||||
const layout = useWindowDimensions()
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
{...props}
|
||||
style={{
|
||||
...(props.style as any),
|
||||
backgroundColor: colors.gradient.low,
|
||||
}}
|
||||
ListHeaderComponent={() => <GradientBackground position="relative" />}
|
||||
ListHeaderComponentStyle={{
|
||||
marginBottom: -layout.height,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default GradientFlatList
|
||||
18
app/components/GradientScrollView.tsx
Normal file
18
app/components/GradientScrollView.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import React from 'react'
|
||||
import { ScrollView, ScrollViewProps, ViewStyle } from 'react-native'
|
||||
import colors from '@app/styles/colors'
|
||||
import GradientBackground from '@app/components/GradientBackground'
|
||||
|
||||
const GradientScrollView: React.FC<ScrollViewProps> = props => {
|
||||
props.style = props.style || {}
|
||||
;(props.style as ViewStyle).backgroundColor = colors.gradient.low
|
||||
|
||||
return (
|
||||
<ScrollView overScrollMode="never" {...props}>
|
||||
<GradientBackground />
|
||||
{props.children}
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
|
||||
export default GradientScrollView
|
||||
70
app/components/ImageGradientBackground.tsx
Normal file
70
app/components/ImageGradientBackground.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useNavigation } from '@react-navigation/native'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { ViewStyle } from 'react-native'
|
||||
import FastImage from 'react-native-fast-image'
|
||||
import ImageColors from 'react-native-image-colors'
|
||||
import { AndroidImageColors } from 'react-native-image-colors/lib/typescript/types'
|
||||
import colors from '@app/styles/colors'
|
||||
import GradientBackground from '@app/components/GradientBackground'
|
||||
|
||||
const ImageGradientBackground: React.FC<{
|
||||
height?: number | string
|
||||
width?: number | string
|
||||
position?: 'relative' | 'absolute'
|
||||
style?: ViewStyle
|
||||
imageUri?: string
|
||||
imageKey?: string
|
||||
}> = ({ height, width, position, style, imageUri, imageKey, children }) => {
|
||||
const [highColor, setHighColor] = useState<string>(colors.gradient.high)
|
||||
const navigation = useNavigation()
|
||||
|
||||
useEffect(() => {
|
||||
async function getColors() {
|
||||
if (imageUri === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
const cachedResult = ImageColors.cache.getItem(imageKey ? imageKey : imageUri)
|
||||
|
||||
let res: AndroidImageColors
|
||||
if (cachedResult) {
|
||||
res = cachedResult as AndroidImageColors
|
||||
} else {
|
||||
const path = await FastImage.getCachePath({ uri: imageUri })
|
||||
res = (await ImageColors.getColors(path ? `file://${path}` : imageUri, {
|
||||
cache: true,
|
||||
key: imageKey ? imageKey : imageUri,
|
||||
})) as AndroidImageColors
|
||||
}
|
||||
|
||||
if (res.muted && res.muted !== '#000000') {
|
||||
setHighColor(res.muted)
|
||||
} else if (res.darkMuted && res.darkMuted !== '#000000') {
|
||||
setHighColor(res.darkMuted)
|
||||
}
|
||||
}
|
||||
getColors()
|
||||
}, [imageUri, imageKey])
|
||||
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerStyle: {
|
||||
backgroundColor: highColor,
|
||||
},
|
||||
})
|
||||
}, [navigation, highColor])
|
||||
|
||||
return (
|
||||
<GradientBackground
|
||||
height={height}
|
||||
width={width}
|
||||
position={position}
|
||||
style={style}
|
||||
colors={[highColor, colors.gradient.low]}
|
||||
locations={[0.1, 1.0]}>
|
||||
{children}
|
||||
</GradientBackground>
|
||||
)
|
||||
}
|
||||
|
||||
export default ImageGradientBackground
|
||||
30
app/components/ImageGradientScrollView.tsx
Normal file
30
app/components/ImageGradientScrollView.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import React, { useState } from 'react'
|
||||
import { LayoutRectangle, ScrollView, ScrollViewProps } from 'react-native'
|
||||
import colors from '@app/styles/colors'
|
||||
import ImageGradientBackground from '@app/components/ImageGradientBackground'
|
||||
|
||||
const ImageGradientScrollView: React.FC<ScrollViewProps & { imageUri?: string; imageKey?: string }> = props => {
|
||||
const [layout, setLayout] = useState<LayoutRectangle | undefined>(undefined)
|
||||
|
||||
props.style = props.style || {}
|
||||
if (typeof props.style === 'object' && props.style !== null) {
|
||||
props.style = {
|
||||
...props.style,
|
||||
backgroundColor: colors.gradient.low,
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
overScrollMode="never"
|
||||
{...props}
|
||||
onLayout={event => {
|
||||
setLayout(event.nativeEvent.layout)
|
||||
}}>
|
||||
<ImageGradientBackground height={layout?.height} imageUri={props.imageUri} imageKey={props.imageKey} />
|
||||
{props.children}
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
|
||||
export default ImageGradientScrollView
|
||||
132
app/components/NowPlayingBar.tsx
Normal file
132
app/components/NowPlayingBar.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
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 colors from '@app/styles/colors'
|
||||
import { Font } from '@app/styles/text'
|
||||
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()
|
||||
|
||||
let progress = 0
|
||||
if (duration > 0) {
|
||||
progress = position / duration
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={progressStyles.container}>
|
||||
<View style={{ ...progressStyles.left, flex: progress }} />
|
||||
<View style={{ ...progressStyles.right, flex: 1 - progress }} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const progressStyles = StyleSheet.create({
|
||||
container: {
|
||||
height: 2,
|
||||
flexDirection: 'row',
|
||||
},
|
||||
left: {
|
||||
backgroundColor: colors.text.primary,
|
||||
},
|
||||
right: {
|
||||
backgroundColor: '#595959',
|
||||
},
|
||||
})
|
||||
|
||||
const NowPlayingBar = () => {
|
||||
const navigation = useNavigation()
|
||||
const track = useAtomValue(currentTrackAtom)
|
||||
const playerState = useAtomValue(playerStateAtom)
|
||||
const play = usePlay()
|
||||
const pause = usePause()
|
||||
|
||||
let playPauseIcon: string
|
||||
let playPauseAction: () => void
|
||||
|
||||
switch (playerState) {
|
||||
case State.Playing:
|
||||
case State.Buffering:
|
||||
case State.Connecting:
|
||||
playPauseIcon = 'pause'
|
||||
playPauseAction = pause
|
||||
break
|
||||
default:
|
||||
playPauseIcon = 'play'
|
||||
playPauseAction = play
|
||||
break
|
||||
}
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => navigation.navigate('NowPlaying')}
|
||||
style={{ ...styles.container, display: track ? 'flex' : 'none' }}>
|
||||
<ProgressBar />
|
||||
<View style={styles.subContainer}>
|
||||
<CoverArt
|
||||
PlaceholderComponent={() => <Text>hi</Text>}
|
||||
height={styles.subContainer.height}
|
||||
width={styles.subContainer.height}
|
||||
coverArtUri={track?.artworkThumb}
|
||||
/>
|
||||
<View style={styles.detailsContainer}>
|
||||
<Text numberOfLines={1} style={styles.detailsTitle}>
|
||||
{track?.title}
|
||||
</Text>
|
||||
<Text numberOfLines={1} style={styles.detailsAlbum}>
|
||||
{track?.artist}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.controls}>
|
||||
<PressableOpacity onPress={playPauseAction} hitSlop={14}>
|
||||
<IconFA5 name={playPauseIcon} size={28} color="white" />
|
||||
</PressableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
width: '100%',
|
||||
backgroundColor: colors.gradient.high,
|
||||
borderBottomColor: colors.gradient.low,
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
subContainer: {
|
||||
height: 60,
|
||||
flexDirection: 'row',
|
||||
},
|
||||
detailsContainer: {
|
||||
flex: 1,
|
||||
height: '100%',
|
||||
justifyContent: 'center',
|
||||
marginLeft: 10,
|
||||
},
|
||||
detailsTitle: {
|
||||
fontFamily: Font.semiBold,
|
||||
fontSize: 13,
|
||||
color: colors.text.primary,
|
||||
},
|
||||
detailsAlbum: {
|
||||
fontFamily: Font.regular,
|
||||
fontSize: 13,
|
||||
color: colors.text.secondary,
|
||||
},
|
||||
controls: {
|
||||
flexDirection: 'row',
|
||||
height: '100%',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 18,
|
||||
marginLeft: 12,
|
||||
},
|
||||
})
|
||||
|
||||
export default NowPlayingBar
|
||||
44
app/components/PressableOpacity.tsx
Normal file
44
app/components/PressableOpacity.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { LayoutRectangle, Pressable, PressableProps } from 'react-native'
|
||||
|
||||
type PressableOpacityProps = PressableProps & {
|
||||
ripple?: boolean
|
||||
}
|
||||
|
||||
const PressableOpacity: React.FC<PressableOpacityProps> = props => {
|
||||
const [opacity, setOpacity] = useState(1)
|
||||
const [dimensions, setDimensions] = useState<LayoutRectangle | undefined>(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
props.disabled === true ? setOpacity(0.3) : setOpacity(1)
|
||||
}, [props.disabled])
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
{...props}
|
||||
style={[{ justifyContent: 'center', alignItems: 'center' }, props.style as any, { opacity }]}
|
||||
android_ripple={
|
||||
props.ripple
|
||||
? {
|
||||
color: 'rgba(255,255,255,0.26)',
|
||||
radius: dimensions ? dimensions.width / 2 : undefined,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onLayout={event => setDimensions(event.nativeEvent.layout)}
|
||||
onPressIn={() => {
|
||||
if (!props.disabled) {
|
||||
setOpacity(0.4)
|
||||
}
|
||||
}}
|
||||
onPressOut={() => {
|
||||
if (!props.disabled) {
|
||||
setOpacity(1)
|
||||
}
|
||||
}}>
|
||||
{props.children}
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(PressableOpacity)
|
||||
157
app/components/TrackPlayerState.tsx
Normal file
157
app/components/TrackPlayerState.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
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,
|
||||
queueWriteAtom,
|
||||
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 <AppActiveResponder update={update} />
|
||||
}
|
||||
|
||||
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 (
|
||||
<TrackPlayerEventResponder
|
||||
events={[
|
||||
Event.PlaybackQueueEnded,
|
||||
Event.PlaybackMetadataReceived,
|
||||
Event.RemoteDuck,
|
||||
Event.RemoteNext,
|
||||
Event.RemotePrevious,
|
||||
Event.RemoteStop,
|
||||
]}
|
||||
update={update}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
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 <TrackPlayerEventResponder events={[Event.PlaybackState, Event.RemoteStop]} update={update} />
|
||||
}
|
||||
|
||||
const QueueState = () => {
|
||||
const setQueue = useUpdateAtom(queueWriteAtom)
|
||||
const refreshQueue = useRefreshQueue()
|
||||
|
||||
const update = async (payload?: Payload) => {
|
||||
if (payload) {
|
||||
setQueue([])
|
||||
return
|
||||
}
|
||||
await refreshQueue()
|
||||
}
|
||||
|
||||
return <TrackPlayerEventResponder events={[Event.RemoteStop]} update={update} />
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<ProgressHook />
|
||||
<TrackPlayerEventResponder events={[Event.RemoteStop, Event.PlaybackTrackChanged]} update={update} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
return <TrackPlayerEventResponder events={[Event.RemoteStop]} update={update} />
|
||||
}
|
||||
|
||||
const Debug = () => {
|
||||
const value = useAtomValue(currentTrackAtom)
|
||||
|
||||
useEffect(() => {
|
||||
// ToastAndroid.show(value?.title || 'undefined', 1)
|
||||
}, [value])
|
||||
|
||||
return <></>
|
||||
}
|
||||
|
||||
const TrackPlayerState = () => (
|
||||
<View>
|
||||
<CurrentTrackState />
|
||||
<PlayerState />
|
||||
<QueueState />
|
||||
<ProgressState />
|
||||
<Debug />
|
||||
</View>
|
||||
)
|
||||
|
||||
export default TrackPlayerState
|
||||
95
app/models/music.ts
Normal file
95
app/models/music.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
export interface Artist {
|
||||
id: string
|
||||
name: string
|
||||
starred?: Date
|
||||
}
|
||||
|
||||
export interface ArtistInfo extends Artist {
|
||||
albums: Album[]
|
||||
|
||||
mediumImageUrl?: string
|
||||
largeImageUrl?: string
|
||||
coverArtUris: string[]
|
||||
}
|
||||
|
||||
export interface ArtistArt {
|
||||
uri?: string
|
||||
coverArtUris: string[]
|
||||
}
|
||||
|
||||
export interface Album {
|
||||
id: string
|
||||
artistId?: string
|
||||
artist?: string
|
||||
name: string
|
||||
starred?: Date
|
||||
coverArt?: string
|
||||
coverArtUri?: string
|
||||
coverArtThumbUri?: string
|
||||
year?: number
|
||||
}
|
||||
|
||||
export interface AlbumArt {
|
||||
uri?: string
|
||||
thumbUri?: string
|
||||
}
|
||||
|
||||
export interface AlbumWithSongs extends Album {
|
||||
songs: Song[]
|
||||
}
|
||||
|
||||
export interface Song {
|
||||
id: string
|
||||
album?: string
|
||||
artist?: string
|
||||
title: string
|
||||
track?: number
|
||||
year?: number
|
||||
genre?: string
|
||||
coverArt?: string
|
||||
size?: number
|
||||
contentType?: string
|
||||
suffix?: string
|
||||
duration?: number
|
||||
bitRate?: number
|
||||
userRating?: number
|
||||
averageRating?: number
|
||||
playCount?: number
|
||||
discNumber?: number
|
||||
created?: Date
|
||||
starred?: Date
|
||||
|
||||
streamUri: string
|
||||
coverArtUri?: string
|
||||
coverArtThumbUri?: string
|
||||
}
|
||||
|
||||
export type DownloadedSong = {
|
||||
id: string
|
||||
type: 'song'
|
||||
name: string
|
||||
album: string
|
||||
artist: string
|
||||
}
|
||||
|
||||
export type DownloadedAlbum = {
|
||||
id: string
|
||||
type: 'album'
|
||||
songs: string[]
|
||||
name: string
|
||||
artist: string
|
||||
}
|
||||
|
||||
export type DownloadedArtist = {
|
||||
id: string
|
||||
type: 'artist'
|
||||
songs: string[]
|
||||
name: string
|
||||
}
|
||||
|
||||
export type DownloadedPlaylist = {
|
||||
id: string
|
||||
type: 'playlist'
|
||||
songs: string[]
|
||||
name: string
|
||||
}
|
||||
12
app/models/settings.ts
Normal file
12
app/models/settings.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export interface Server {
|
||||
id: string
|
||||
address: string
|
||||
username: string
|
||||
token: string
|
||||
salt: string
|
||||
}
|
||||
|
||||
export interface AppSettings {
|
||||
servers: Server[]
|
||||
activeServer?: string
|
||||
}
|
||||
118
app/navigation/BottomTabBar.tsx
Normal file
118
app/navigation/BottomTabBar.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Text, View, Pressable } from 'react-native'
|
||||
import { BottomTabBarProps } from '@react-navigation/bottom-tabs'
|
||||
import textStyles from '@app/styles/text'
|
||||
import colors from '@app/styles/colors'
|
||||
import FastImage from 'react-native-fast-image'
|
||||
import NowPlayingBar from '@app/components/NowPlayingBar'
|
||||
|
||||
const icons: { [key: string]: any } = {
|
||||
home: {
|
||||
regular: require('../../res/home.png'),
|
||||
fill: require('../../res/home-fill.png'),
|
||||
},
|
||||
library: {
|
||||
regular: require('../../res/library.png'),
|
||||
fill: require('../../res/library-fill.png'),
|
||||
},
|
||||
search: {
|
||||
regular: require('../../res/search.png'),
|
||||
fill: require('../../res/search-fill.png'),
|
||||
},
|
||||
settings: {
|
||||
regular: require('../../res/settings.png'),
|
||||
fill: require('../../res/settings-fill.png'),
|
||||
},
|
||||
}
|
||||
|
||||
const BottomTabButton: React.FC<{
|
||||
routeKey: string
|
||||
label: string
|
||||
name: string
|
||||
isFocused: boolean
|
||||
img: { regular: number; fill: number }
|
||||
navigation: any
|
||||
}> = ({ routeKey, label, name, isFocused, img, navigation }) => {
|
||||
const [opacity, setOpacity] = useState(1)
|
||||
|
||||
const onPress = () => {
|
||||
const event = navigation.emit({
|
||||
type: 'tabPress',
|
||||
target: routeKey,
|
||||
canPreventDefault: true,
|
||||
})
|
||||
|
||||
if (!isFocused && !event.defaultPrevented) {
|
||||
navigation.navigate(name)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onPressIn={() => setOpacity(0.6)}
|
||||
onPressOut={() => setOpacity(1)}
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
opacity,
|
||||
}}>
|
||||
<FastImage
|
||||
source={isFocused ? img.fill : img.regular}
|
||||
style={{
|
||||
height: 26,
|
||||
width: 26,
|
||||
}}
|
||||
tintColor={isFocused ? colors.text.primary : colors.text.secondary}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
...textStyles.xsmall,
|
||||
color: isFocused ? colors.text.primary : colors.text.secondary,
|
||||
}}>
|
||||
{label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
const BottomTabBar: React.FC<BottomTabBarProps> = ({ state, descriptors, navigation }) => {
|
||||
return (
|
||||
<View>
|
||||
<NowPlayingBar />
|
||||
<View
|
||||
style={{
|
||||
height: 54,
|
||||
backgroundColor: colors.gradient.high,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-around',
|
||||
paddingHorizontal: 28,
|
||||
}}>
|
||||
{state.routes.map((route, index) => {
|
||||
const { options } = descriptors[route.key] as any
|
||||
const label =
|
||||
options.tabBarLabel !== undefined
|
||||
? (options.tabBarLabel as string)
|
||||
: options.title !== undefined
|
||||
? options.title
|
||||
: route.name
|
||||
|
||||
return (
|
||||
<BottomTabButton
|
||||
key={route.key}
|
||||
routeKey={route.key}
|
||||
label={label}
|
||||
name={route.name}
|
||||
isFocused={state.index === index}
|
||||
img={icons[options.icon]}
|
||||
navigation={navigation}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default BottomTabBar
|
||||
22
app/navigation/BottomTabNavigator.tsx
Normal file
22
app/navigation/BottomTabNavigator.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from 'react'
|
||||
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'
|
||||
import SettingsView from '@app/screens/Settings'
|
||||
import NowPlayingLayout from '@app/screens/NowPlayingLayout'
|
||||
import ArtistsList from '@app/screens/ArtistsList'
|
||||
import LibraryTopTabNavigator from '@app/navigation/LibraryTopTabNavigator'
|
||||
import BottomTabBar from '@app/navigation/BottomTabBar'
|
||||
|
||||
const Tab = createBottomTabNavigator()
|
||||
|
||||
const BottomTabNavigator = () => {
|
||||
return (
|
||||
<Tab.Navigator tabBar={BottomTabBar}>
|
||||
<Tab.Screen name="Home" component={ArtistsList} options={{ icon: 'home' } as any} />
|
||||
<Tab.Screen name="Library" component={LibraryTopTabNavigator} options={{ icon: 'library' } as any} />
|
||||
<Tab.Screen name="Search" component={NowPlayingLayout} options={{ icon: 'search' } as any} />
|
||||
<Tab.Screen name="Settings" component={SettingsView} options={{ icon: 'settings' } as any} />
|
||||
</Tab.Navigator>
|
||||
)
|
||||
}
|
||||
|
||||
export default BottomTabNavigator
|
||||
93
app/navigation/LibraryTopTabNavigator.tsx
Normal file
93
app/navigation/LibraryTopTabNavigator.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import React from 'react'
|
||||
import { StatusBar, View } from 'react-native'
|
||||
import { createMaterialTopTabNavigator } from '@react-navigation/material-top-tabs'
|
||||
import AlbumsTab from '@app/screens/LibraryAlbums'
|
||||
import ArtistsTab from '@app/screens/LibraryArtists'
|
||||
import PlaylistsTab from '@app/screens/LibraryPlaylists'
|
||||
import { createNativeStackNavigator, NativeStackNavigationProp } from 'react-native-screens/native-stack'
|
||||
import AlbumView from '@app/screens/AlbumView'
|
||||
import { RouteProp } from '@react-navigation/native'
|
||||
import text from '@app/styles/text'
|
||||
import colors from '@app/styles/colors'
|
||||
import ArtistView from '@app/screens/ArtistView'
|
||||
|
||||
const Tab = createMaterialTopTabNavigator()
|
||||
|
||||
const LibraryTopTabNavigator = () => (
|
||||
<Tab.Navigator
|
||||
tabBarOptions={{
|
||||
style: {
|
||||
height: 48,
|
||||
backgroundColor: colors.gradient.high,
|
||||
elevation: 0,
|
||||
marginTop: StatusBar.currentHeight,
|
||||
},
|
||||
labelStyle: {
|
||||
...text.header,
|
||||
textTransform: null as any,
|
||||
marginTop: 0,
|
||||
marginHorizontal: 2,
|
||||
},
|
||||
indicatorStyle: {
|
||||
backgroundColor: colors.text.primary,
|
||||
},
|
||||
}}>
|
||||
<Tab.Screen name="Albums" component={AlbumsTab} />
|
||||
<Tab.Screen name="Artists" component={ArtistsTab} />
|
||||
<Tab.Screen name="Playlists" component={PlaylistsTab} />
|
||||
</Tab.Navigator>
|
||||
)
|
||||
|
||||
type LibraryStackParamList = {
|
||||
LibraryTopTabs: undefined
|
||||
AlbumView: { id: string; title: string }
|
||||
ArtistView: { id: string; title: string }
|
||||
}
|
||||
|
||||
type AlbumScreenNavigationProp = NativeStackNavigationProp<LibraryStackParamList, 'AlbumView'>
|
||||
type AlbumScreenRouteProp = RouteProp<LibraryStackParamList, 'AlbumView'>
|
||||
type AlbumScreenProps = {
|
||||
route: AlbumScreenRouteProp
|
||||
navigation: AlbumScreenNavigationProp
|
||||
}
|
||||
|
||||
const AlbumScreen: React.FC<AlbumScreenProps> = ({ route }) => (
|
||||
<AlbumView id={route.params.id} title={route.params.title} />
|
||||
)
|
||||
|
||||
type ArtistScreenNavigationProp = NativeStackNavigationProp<LibraryStackParamList, 'ArtistView'>
|
||||
type ArtistScreenRouteProp = RouteProp<LibraryStackParamList, 'ArtistView'>
|
||||
type ArtistScreenProps = {
|
||||
route: ArtistScreenRouteProp
|
||||
navigation: ArtistScreenNavigationProp
|
||||
}
|
||||
|
||||
const ArtistScreen: React.FC<ArtistScreenProps> = ({ route }) => (
|
||||
<ArtistView id={route.params.id} title={route.params.title} />
|
||||
)
|
||||
|
||||
const Stack = createNativeStackNavigator<LibraryStackParamList>()
|
||||
|
||||
const itemScreenOptions = {
|
||||
title: '',
|
||||
headerStyle: {
|
||||
backgroundColor: colors.gradient.high,
|
||||
},
|
||||
headerHideShadow: true,
|
||||
headerTintColor: 'white',
|
||||
headerTitleStyle: {
|
||||
...text.header,
|
||||
} as any,
|
||||
}
|
||||
|
||||
const LibraryStackNavigator = () => (
|
||||
<View style={{ flex: 1 }}>
|
||||
<Stack.Navigator>
|
||||
<Stack.Screen name="LibraryTopTabs" component={LibraryTopTabNavigator} options={{ headerShown: false }} />
|
||||
<Stack.Screen name="AlbumView" component={AlbumScreen} options={itemScreenOptions} />
|
||||
<Stack.Screen name="ArtistView" component={ArtistScreen} options={itemScreenOptions} />
|
||||
</Stack.Navigator>
|
||||
</View>
|
||||
)
|
||||
|
||||
export default LibraryStackNavigator
|
||||
18
app/navigation/RootNavigator.tsx
Normal file
18
app/navigation/RootNavigator.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import React from 'react'
|
||||
import { createNativeStackNavigator } from 'react-native-screens/native-stack'
|
||||
import NowPlayingLayout from '@app/screens/NowPlayingLayout'
|
||||
import BottomTabNavigator from '@app/navigation/BottomTabNavigator'
|
||||
|
||||
const RootStack = createNativeStackNavigator()
|
||||
|
||||
const RootNavigator = () => (
|
||||
<RootStack.Navigator
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
}}>
|
||||
<RootStack.Screen name="Main" component={BottomTabNavigator} />
|
||||
<RootStack.Screen name="NowPlaying" component={NowPlayingLayout} />
|
||||
</RootStack.Navigator>
|
||||
)
|
||||
|
||||
export default RootNavigator
|
||||
194
app/screens/AlbumView.tsx
Normal file
194
app/screens/AlbumView.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import { useNavigation } from '@react-navigation/native'
|
||||
import { useAtomValue } from 'jotai/utils'
|
||||
import React, { useEffect } from 'react'
|
||||
import { ActivityIndicator, GestureResponderEvent, StyleSheet, Text, useWindowDimensions, View } from 'react-native'
|
||||
import IconFA from 'react-native-vector-icons/FontAwesome'
|
||||
import IconMat from 'react-native-vector-icons/MaterialIcons'
|
||||
import { albumAtomFamily } from '@app/state/music'
|
||||
import { currentTrackAtom, useSetQueue } from '@app/state/trackplayer'
|
||||
import colors from '@app/styles/colors'
|
||||
import text, { Font } from '@app/styles/text'
|
||||
import AlbumArt from '@app/components/AlbumArt'
|
||||
import Button from '@app/components/Button'
|
||||
import GradientBackground from '@app/components/GradientBackground'
|
||||
import ImageGradientScrollView from '@app/components/ImageGradientScrollView'
|
||||
import PressableOpacity from '@app/components/PressableOpacity'
|
||||
|
||||
const SongItem: React.FC<{
|
||||
id: string
|
||||
title: string
|
||||
artist?: string
|
||||
track?: number
|
||||
onPress: (event: GestureResponderEvent) => void
|
||||
}> = ({ id, title, artist, onPress }) => {
|
||||
const currentTrack = useAtomValue(currentTrackAtom)
|
||||
|
||||
return (
|
||||
<View style={songStyles.container}>
|
||||
<PressableOpacity onPress={onPress} style={songStyles.text}>
|
||||
<Text style={{ ...songStyles.title, color: currentTrack?.id === id ? colors.accent : colors.text.primary }}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text style={songStyles.subtitle}>{artist}</Text>
|
||||
</PressableOpacity>
|
||||
<View style={songStyles.controls}>
|
||||
<PressableOpacity onPress={undefined}>
|
||||
<IconFA name="star-o" size={26} color={colors.text.primary} />
|
||||
</PressableOpacity>
|
||||
<PressableOpacity onPress={undefined} style={songStyles.more}>
|
||||
<IconMat name="more-vert" size={32} color="white" />
|
||||
</PressableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const songStyles = StyleSheet.create({
|
||||
container: {
|
||||
marginTop: 20,
|
||||
marginLeft: 10,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
text: {
|
||||
flex: 1,
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
title: {
|
||||
fontSize: 16,
|
||||
fontFamily: Font.semiBold,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 14,
|
||||
fontFamily: Font.regular,
|
||||
color: colors.text.secondary,
|
||||
},
|
||||
controls: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginLeft: 10,
|
||||
},
|
||||
more: {
|
||||
marginLeft: 8,
|
||||
},
|
||||
})
|
||||
|
||||
const AlbumDetails: React.FC<{
|
||||
id: string
|
||||
}> = ({ id }) => {
|
||||
const album = useAtomValue(albumAtomFamily(id))
|
||||
const layout = useWindowDimensions()
|
||||
const setQueue = useSetQueue()
|
||||
|
||||
const coverSize = layout.width - layout.width / 2.5
|
||||
|
||||
if (!album) {
|
||||
return <Text style={text.paragraph}>No Album</Text>
|
||||
}
|
||||
|
||||
return (
|
||||
<ImageGradientScrollView
|
||||
imageUri={album.coverArtThumbUri}
|
||||
imageKey={`${album.name}${album.artist}`}
|
||||
style={{
|
||||
flex: 1,
|
||||
}}
|
||||
contentContainerStyle={{
|
||||
alignItems: 'center',
|
||||
paddingTop: coverSize / 8,
|
||||
}}>
|
||||
<AlbumArt id={album.id} height={coverSize} width={coverSize} />
|
||||
<Text
|
||||
style={{
|
||||
...text.title,
|
||||
marginTop: 12,
|
||||
width: layout.width - layout.width / 8,
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
{album.name}
|
||||
</Text>
|
||||
|
||||
<Text
|
||||
style={{
|
||||
...text.itemSubtitle,
|
||||
fontSize: 14,
|
||||
marginTop: 4,
|
||||
marginBottom: 20,
|
||||
width: layout.width - layout.width / 8,
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
{album.artist}
|
||||
{album.year ? ` • ${album.year}` : ''}
|
||||
</Text>
|
||||
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
}}>
|
||||
<Button title="Play Album" onPress={() => setQueue(album.songs, album.name, album.songs[0].id)} />
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={{
|
||||
width: layout.width - layout.width / 20,
|
||||
marginTop: 20,
|
||||
marginBottom: 30,
|
||||
}}>
|
||||
{album.songs
|
||||
.sort((a, b) => {
|
||||
if (b.track && a.track) {
|
||||
return a.track - b.track
|
||||
} else {
|
||||
return a.title.localeCompare(b.title)
|
||||
}
|
||||
})
|
||||
.map(s => (
|
||||
<SongItem
|
||||
key={s.id}
|
||||
id={s.id}
|
||||
title={s.title}
|
||||
artist={s.artist}
|
||||
track={s.track}
|
||||
onPress={() => setQueue(album.songs, album.name, s.id)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</ImageGradientScrollView>
|
||||
)
|
||||
}
|
||||
|
||||
const AlbumViewFallback = () => {
|
||||
const layout = useWindowDimensions()
|
||||
|
||||
const coverSize = layout.width - layout.width / 2.5
|
||||
|
||||
return (
|
||||
<GradientBackground
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
paddingTop: coverSize / 8 + coverSize / 2 - 18,
|
||||
}}>
|
||||
<ActivityIndicator size="large" color={colors.accent} />
|
||||
</GradientBackground>
|
||||
)
|
||||
}
|
||||
|
||||
const AlbumView: React.FC<{
|
||||
id: string
|
||||
title: string
|
||||
}> = ({ id, title }) => {
|
||||
const navigation = useNavigation()
|
||||
|
||||
useEffect(() => {
|
||||
navigation.setOptions({ title })
|
||||
})
|
||||
|
||||
return (
|
||||
<React.Suspense fallback={<AlbumViewFallback />}>
|
||||
<AlbumDetails id={id} />
|
||||
</React.Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(AlbumView)
|
||||
49
app/screens/ArtistView.tsx
Normal file
49
app/screens/ArtistView.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useNavigation } from '@react-navigation/native'
|
||||
import { useAtomValue } from 'jotai/utils'
|
||||
import React, { useEffect } from 'react'
|
||||
import { Text } from 'react-native'
|
||||
import { artistInfoAtomFamily } from '@app/state/music'
|
||||
import text from '@app/styles/text'
|
||||
import ArtistArt from '@app/components/ArtistArt'
|
||||
import GradientScrollView from '@app/components/GradientScrollView'
|
||||
|
||||
const ArtistDetails: React.FC<{ id: string }> = ({ id }) => {
|
||||
const artist = useAtomValue(artistInfoAtomFamily(id))
|
||||
|
||||
if (!artist) {
|
||||
return <></>
|
||||
}
|
||||
|
||||
return (
|
||||
<GradientScrollView
|
||||
style={{
|
||||
flex: 1,
|
||||
}}
|
||||
contentContainerStyle={{
|
||||
alignItems: 'center',
|
||||
// paddingTop: coverSize / 8,
|
||||
}}>
|
||||
<Text style={text.paragraph}>{artist.name}</Text>
|
||||
<ArtistArt id={artist.id} height={200} width={200} />
|
||||
</GradientScrollView>
|
||||
)
|
||||
}
|
||||
|
||||
const ArtistView: React.FC<{
|
||||
id: string
|
||||
title: string
|
||||
}> = ({ id, title }) => {
|
||||
const navigation = useNavigation()
|
||||
|
||||
useEffect(() => {
|
||||
navigation.setOptions({ title })
|
||||
})
|
||||
|
||||
return (
|
||||
<React.Suspense fallback={<Text>Loading...</Text>}>
|
||||
<ArtistDetails id={id} />
|
||||
</React.Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(ArtistView)
|
||||
36
app/screens/ArtistsList.tsx
Normal file
36
app/screens/ArtistsList.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React from 'react'
|
||||
import { FlatList, Text, View } from 'react-native'
|
||||
import { useAtomValue } from 'jotai/utils'
|
||||
import { Artist } from '@app/models/music'
|
||||
import { artistsAtom } from '@app/state/music'
|
||||
|
||||
const ArtistItem: React.FC<{ item: Artist }> = ({ item }) => (
|
||||
<View>
|
||||
<Text>{item.id}</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 60,
|
||||
paddingBottom: 400,
|
||||
}}>
|
||||
{item.name}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
|
||||
const List = () => {
|
||||
const artists = useAtomValue(artistsAtom)
|
||||
|
||||
const renderItem: React.FC<{ item: Artist }> = ({ item }) => <ArtistItem item={item} />
|
||||
|
||||
return <FlatList data={artists} renderItem={renderItem} keyExtractor={item => item.id} />
|
||||
}
|
||||
|
||||
const ArtistsList = () => (
|
||||
<View>
|
||||
<React.Suspense fallback={<Text>Loading...</Text>}>
|
||||
<List />
|
||||
</React.Suspense>
|
||||
</View>
|
||||
)
|
||||
|
||||
export default ArtistsList
|
||||
90
app/screens/LibraryAlbums.tsx
Normal file
90
app/screens/LibraryAlbums.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useNavigation } from '@react-navigation/native'
|
||||
import { useAtomValue } from 'jotai/utils'
|
||||
import React, { useEffect } from 'react'
|
||||
import { Pressable, Text, View } from 'react-native'
|
||||
import { Album } from '@app/models/music'
|
||||
import { albumsAtom, albumsUpdatingAtom, useUpdateAlbums } from '@app/state/music'
|
||||
import textStyles from '@app/styles/text'
|
||||
import AlbumArt from '@app/components/AlbumArt'
|
||||
import GradientFlatList from '@app/components/GradientFlatList'
|
||||
|
||||
const AlbumItem: React.FC<{
|
||||
id: string
|
||||
name: string
|
||||
artist?: string
|
||||
}> = ({ id, name, artist }) => {
|
||||
const navigation = useNavigation()
|
||||
|
||||
const size = 125
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
marginVertical: 8,
|
||||
flex: 1 / 3,
|
||||
}}
|
||||
onPress={() => navigation.navigate('AlbumView', { id, title: name })}>
|
||||
<AlbumArt id={id} height={size} width={size} />
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
width: size,
|
||||
}}>
|
||||
<Text
|
||||
style={{
|
||||
...textStyles.itemTitle,
|
||||
marginTop: 4,
|
||||
}}
|
||||
numberOfLines={2}>
|
||||
{name}
|
||||
</Text>
|
||||
<Text style={{ ...textStyles.itemSubtitle }} numberOfLines={1}>
|
||||
{artist}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
const MemoAlbumItem = React.memo(AlbumItem)
|
||||
|
||||
const AlbumListRenderItem: React.FC<{ item: Album }> = ({ item }) => (
|
||||
<MemoAlbumItem id={item.id} name={item.name} artist={item.artist} />
|
||||
)
|
||||
|
||||
const AlbumsList = () => {
|
||||
const albums = useAtomValue(albumsAtom)
|
||||
const updating = useAtomValue(albumsUpdatingAtom)
|
||||
const updateAlbums = useUpdateAlbums()
|
||||
|
||||
const albumsList = Object.values(albums)
|
||||
|
||||
useEffect(() => {
|
||||
if (albumsList.length === 0) {
|
||||
updateAlbums()
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1 }}>
|
||||
<GradientFlatList
|
||||
data={albumsList}
|
||||
renderItem={AlbumListRenderItem}
|
||||
keyExtractor={item => item.id}
|
||||
numColumns={3}
|
||||
removeClippedSubviews={true}
|
||||
refreshing={updating}
|
||||
onRefresh={updateAlbums}
|
||||
overScrollMode="never"
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const AlbumsTab = () => (
|
||||
<React.Suspense fallback={<Text>Loading...</Text>}>
|
||||
<AlbumsList />
|
||||
</React.Suspense>
|
||||
)
|
||||
|
||||
export default React.memo(AlbumsTab)
|
||||
69
app/screens/LibraryArtists.tsx
Normal file
69
app/screens/LibraryArtists.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { useNavigation } from '@react-navigation/native'
|
||||
import { useAtomValue } from 'jotai/utils'
|
||||
import React, { useEffect } from 'react'
|
||||
import { Pressable } from 'react-native'
|
||||
import { Text } from 'react-native'
|
||||
import { Artist } from '@app/models/music'
|
||||
import { artistsAtom, artistsUpdatingAtom, useUpdateArtists } from '@app/state/music'
|
||||
import textStyles from '@app/styles/text'
|
||||
import ArtistArt from '@app/components/ArtistArt'
|
||||
import GradientFlatList from '@app/components/GradientFlatList'
|
||||
|
||||
const ArtistItem: React.FC<{ item: Artist }> = ({ item }) => {
|
||||
const navigation = useNavigation()
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginVertical: 6,
|
||||
marginLeft: 6,
|
||||
}}
|
||||
onPress={() => navigation.navigate('ArtistView', { id: item.id, title: item.name })}>
|
||||
<ArtistArt id={item.id} width={56} height={56} />
|
||||
<Text
|
||||
style={{
|
||||
...textStyles.paragraph,
|
||||
marginLeft: 12,
|
||||
}}>
|
||||
{item.name}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
const ArtistItemLoader: React.FC<{ item: Artist }> = props => (
|
||||
<React.Suspense fallback={<Text>Loading...</Text>}>
|
||||
<ArtistItem {...props} />
|
||||
</React.Suspense>
|
||||
)
|
||||
|
||||
const ArtistsList = () => {
|
||||
const artists = useAtomValue(artistsAtom)
|
||||
const updating = useAtomValue(artistsUpdatingAtom)
|
||||
const updateArtists = useUpdateArtists()
|
||||
|
||||
useEffect(() => {
|
||||
if (artists.length === 0) {
|
||||
updateArtists()
|
||||
}
|
||||
})
|
||||
|
||||
const renderItem: React.FC<{ item: Artist }> = ({ item }) => <ArtistItemLoader item={item} />
|
||||
|
||||
return (
|
||||
<GradientFlatList
|
||||
data={artists}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={item => item.id}
|
||||
onRefresh={updateArtists}
|
||||
refreshing={updating}
|
||||
overScrollMode="never"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const ArtistsTab = () => <ArtistsList />
|
||||
|
||||
export default ArtistsTab
|
||||
6
app/screens/LibraryPlaylists.tsx
Normal file
6
app/screens/LibraryPlaylists.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import React from 'react'
|
||||
import GradientBackground from '@app/components/GradientBackground'
|
||||
|
||||
const PlaylistsTab = () => <GradientBackground />
|
||||
|
||||
export default PlaylistsTab
|
||||
340
app/screens/NowPlayingLayout.tsx
Normal file
340
app/screens/NowPlayingLayout.tsx
Normal file
@@ -0,0 +1,340 @@
|
||||
import { useNavigation } from '@react-navigation/native'
|
||||
import { useAtomValue } from 'jotai/utils'
|
||||
import React, { useEffect } from 'react'
|
||||
import { StatusBar, StyleSheet, Text, View } from 'react-native'
|
||||
import { NativeStackScreenProps } from 'react-native-screens/lib/typescript/native-stack'
|
||||
import { State } from 'react-native-track-player'
|
||||
import IconFA from 'react-native-vector-icons/FontAwesome'
|
||||
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,
|
||||
useNext,
|
||||
usePause,
|
||||
usePlay,
|
||||
usePrevious,
|
||||
useProgress,
|
||||
} from '@app/state/trackplayer'
|
||||
import colors from '@app/styles/colors'
|
||||
import { Font } from '@app/styles/text'
|
||||
import formatDuration from '@app/util/formatDuration'
|
||||
import CoverArt from '@app/components/CoverArt'
|
||||
import ImageGradientBackground from '@app/components/ImageGradientBackground'
|
||||
import PressableOpacity from '@app/components/PressableOpacity'
|
||||
|
||||
const NowPlayingHeader = () => {
|
||||
const queueName = useAtomValue(queueNameAtom)
|
||||
const navigation = useNavigation()
|
||||
|
||||
return (
|
||||
<View style={headerStyles.container}>
|
||||
<PressableOpacity onPress={() => navigation.goBack()} style={headerStyles.icons} ripple={true}>
|
||||
<IconMat name="arrow-back" color="white" size={25} />
|
||||
</PressableOpacity>
|
||||
<Text numberOfLines={1} style={headerStyles.queueName}>
|
||||
{queueName || 'Nothing playing...'}
|
||||
</Text>
|
||||
<PressableOpacity onPress={undefined} style={headerStyles.icons} ripple={true}>
|
||||
<IconMat name="more-vert" color="white" size={25} />
|
||||
</PressableOpacity>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const headerStyles = StyleSheet.create({
|
||||
container: {
|
||||
height: 58,
|
||||
width: '100%',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
icons: {
|
||||
height: 42,
|
||||
width: 42,
|
||||
marginHorizontal: 8,
|
||||
},
|
||||
queueName: {
|
||||
fontFamily: Font.bold,
|
||||
fontSize: 16,
|
||||
color: colors.text.primary,
|
||||
flex: 1,
|
||||
textAlign: 'center',
|
||||
},
|
||||
})
|
||||
|
||||
const SongCoverArt = () => {
|
||||
const track = useAtomValue(currentTrackAtom)
|
||||
|
||||
return (
|
||||
<View style={coverArtStyles.container}>
|
||||
<CoverArt
|
||||
PlaceholderComponent={() => <View style={{ height: '100%', width: '100%' }} />}
|
||||
height={'100%'}
|
||||
width={'100%'}
|
||||
coverArtUri={track?.artwork as string}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const coverArtStyles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
paddingBottom: 20,
|
||||
},
|
||||
})
|
||||
|
||||
const SongInfo = () => {
|
||||
const track = useAtomValue(currentTrackAtom)
|
||||
|
||||
return (
|
||||
<View style={infoStyles.container}>
|
||||
<View style={infoStyles.details}>
|
||||
<Text numberOfLines={1} style={infoStyles.title}>
|
||||
{track?.title}
|
||||
</Text>
|
||||
<Text numberOfLines={1} style={infoStyles.artist}>
|
||||
{track?.artist}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={infoStyles.controls}>
|
||||
<PressableOpacity onPress={undefined}>
|
||||
<IconFA name="star-o" size={32} color={colors.text.secondary} />
|
||||
</PressableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const infoStyles = StyleSheet.create({
|
||||
container: {
|
||||
width: '100%',
|
||||
flexDirection: 'row',
|
||||
},
|
||||
details: {
|
||||
flex: 1,
|
||||
marginRight: 20,
|
||||
},
|
||||
controls: {
|
||||
justifyContent: 'center',
|
||||
},
|
||||
title: {
|
||||
height: 28,
|
||||
fontFamily: Font.bold,
|
||||
fontSize: 22,
|
||||
color: colors.text.primary,
|
||||
},
|
||||
artist: {
|
||||
height: 20,
|
||||
fontFamily: Font.regular,
|
||||
fontSize: 16,
|
||||
color: colors.text.secondary,
|
||||
},
|
||||
})
|
||||
|
||||
const SeekBar = () => {
|
||||
const { position, duration } = useProgress()
|
||||
|
||||
let progress = 0
|
||||
if (duration > 0) {
|
||||
progress = position / duration
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={seekStyles.container}>
|
||||
<View style={seekStyles.barContainer}>
|
||||
<View style={{ ...seekStyles.bars, ...seekStyles.barLeft, flex: progress }} />
|
||||
<View style={{ ...seekStyles.indicator }} />
|
||||
<View style={{ ...seekStyles.bars, ...seekStyles.barRight, flex: 1 - progress }} />
|
||||
</View>
|
||||
<View style={seekStyles.textContainer}>
|
||||
<Text style={seekStyles.text}>{formatDuration(position)}</Text>
|
||||
<Text style={seekStyles.text}>{formatDuration(duration)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const seekStyles = StyleSheet.create({
|
||||
container: {
|
||||
width: '100%',
|
||||
marginTop: 26,
|
||||
},
|
||||
barContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 6,
|
||||
},
|
||||
bars: {
|
||||
backgroundColor: colors.text.primary,
|
||||
height: 4,
|
||||
},
|
||||
barLeft: {
|
||||
marginRight: -6,
|
||||
},
|
||||
barRight: {
|
||||
opacity: 0.3,
|
||||
marginLeft: -6,
|
||||
},
|
||||
indicator: {
|
||||
height: 12,
|
||||
width: 12,
|
||||
borderRadius: 6,
|
||||
backgroundColor: colors.text.primary,
|
||||
elevation: 1,
|
||||
},
|
||||
textContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
text: {
|
||||
fontFamily: Font.regular,
|
||||
fontSize: 15,
|
||||
color: colors.text.primary,
|
||||
},
|
||||
})
|
||||
|
||||
const PlayerControls = () => {
|
||||
const state = useAtomValue(playerStateAtom)
|
||||
const play = usePlay()
|
||||
const pause = usePause()
|
||||
const next = useNext()
|
||||
const previous = usePrevious()
|
||||
|
||||
let playPauseIcon: string
|
||||
let playPauseAction: undefined | (() => void)
|
||||
let disabled: boolean
|
||||
|
||||
switch (state) {
|
||||
case State.Playing:
|
||||
case State.Buffering:
|
||||
case State.Connecting:
|
||||
disabled = false
|
||||
playPauseIcon = 'pause-circle'
|
||||
playPauseAction = pause
|
||||
break
|
||||
case State.Paused:
|
||||
disabled = false
|
||||
playPauseIcon = 'play-circle'
|
||||
playPauseAction = play
|
||||
break
|
||||
default:
|
||||
disabled = true
|
||||
playPauseIcon = 'play-circle'
|
||||
playPauseAction = undefined
|
||||
break
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={controlsStyles.container}>
|
||||
<View style={controlsStyles.top}>
|
||||
<View style={controlsStyles.center}>
|
||||
<PressableOpacity onPress={undefined} disabled={disabled}>
|
||||
<Icon name="repeat" size={26} color="white" />
|
||||
</PressableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={controlsStyles.center}>
|
||||
<PressableOpacity onPress={previous} disabled={disabled}>
|
||||
<IconFA5 name="step-backward" size={36} color="white" />
|
||||
</PressableOpacity>
|
||||
<PressableOpacity onPress={playPauseAction} disabled={disabled} style={controlsStyles.play}>
|
||||
<IconFA name={playPauseIcon} size={82} color="white" />
|
||||
</PressableOpacity>
|
||||
<PressableOpacity onPress={next} disabled={disabled}>
|
||||
<IconFA5 name="step-forward" size={36} color="white" />
|
||||
</PressableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={controlsStyles.center}>
|
||||
<PressableOpacity onPress={undefined} disabled={disabled}>
|
||||
<Icon name="shuffle" size={26} color="white" />
|
||||
</PressableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
<View style={controlsStyles.bottom}>
|
||||
<PressableOpacity onPress={undefined} disabled={disabled}>
|
||||
<IconMatCom name="cast-audio" size={20} color="white" />
|
||||
</PressableOpacity>
|
||||
<PressableOpacity onPress={undefined} disabled={disabled}>
|
||||
<IconMatCom name="playlist-play" size={24} color="white" />
|
||||
</PressableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const controlsStyles = StyleSheet.create({
|
||||
container: {
|
||||
width: '100%',
|
||||
},
|
||||
top: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingBottom: 8,
|
||||
},
|
||||
bottom: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingTop: 10,
|
||||
paddingBottom: 34,
|
||||
},
|
||||
play: {
|
||||
marginHorizontal: 30,
|
||||
},
|
||||
center: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
})
|
||||
|
||||
type RootStackParamList = {
|
||||
Main: undefined
|
||||
NowPlaying: undefined
|
||||
}
|
||||
type NowPlayingProps = NativeStackScreenProps<RootStackParamList, 'NowPlaying'>
|
||||
|
||||
const NowPlayingLayout: React.FC<NowPlayingProps> = ({ navigation }) => {
|
||||
const track = useAtomValue(currentTrackAtom)
|
||||
|
||||
useEffect(() => {
|
||||
if (!track && navigation.canGoBack()) {
|
||||
navigation.popToTop()
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ImageGradientBackground imageUri={track?.artworkThumb as string} imageKey={`${track?.album}${track?.artist}`} />
|
||||
<NowPlayingHeader />
|
||||
<View style={styles.content}>
|
||||
<SongCoverArt />
|
||||
<SongInfo />
|
||||
<SeekBar />
|
||||
<PlayerControls />
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
paddingTop: StatusBar.currentHeight,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
paddingHorizontal: 30,
|
||||
},
|
||||
})
|
||||
|
||||
export default NowPlayingLayout
|
||||
77
app/screens/Settings.tsx
Normal file
77
app/screens/Settings.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { useNavigation } from '@react-navigation/core'
|
||||
import { useAtom } from 'jotai'
|
||||
import md5 from 'md5'
|
||||
import React from 'react'
|
||||
import { Button, Text, View } from 'react-native'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { appSettingsAtom } from '@app/state/settings'
|
||||
import { getAllKeys, multiRemove } from '@app/storage/asyncstorage'
|
||||
import text from '@app/styles/text'
|
||||
|
||||
const TestControls = () => {
|
||||
const navigation = useNavigation()
|
||||
|
||||
const removeAllKeys = async () => {
|
||||
const allKeys = await getAllKeys()
|
||||
await multiRemove(allKeys)
|
||||
}
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Button title="Remove all keys" onPress={removeAllKeys} />
|
||||
<Button title="Now Playing" onPress={() => navigation.navigate('NowPlaying')} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const ServerSettingsView = () => {
|
||||
const [appSettings, setAppSettings] = useAtom(appSettingsAtom)
|
||||
|
||||
const bootstrapServer = () => {
|
||||
if (appSettings.servers.length !== 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const id = uuidv4()
|
||||
const salt = uuidv4()
|
||||
const address = 'http://demo.subsonic.org'
|
||||
|
||||
setAppSettings({
|
||||
...appSettings,
|
||||
servers: [
|
||||
...appSettings.servers,
|
||||
{
|
||||
id,
|
||||
salt,
|
||||
address,
|
||||
username: 'guest',
|
||||
token: md5('guest' + salt),
|
||||
},
|
||||
],
|
||||
activeServer: id,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Button title="Add default server" onPress={bootstrapServer} />
|
||||
{appSettings.servers.map(s => (
|
||||
<View key={s.id}>
|
||||
<Text style={text.paragraph}>{s.address}</Text>
|
||||
<Text style={text.paragraph}>{s.username}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const SettingsView = () => (
|
||||
<View>
|
||||
<TestControls />
|
||||
<React.Suspense fallback={<Text>Loading...</Text>}>
|
||||
<ServerSettingsView />
|
||||
</React.Suspense>
|
||||
</View>
|
||||
)
|
||||
|
||||
export default SettingsView
|
||||
45
app/screens/SplashPage.tsx
Normal file
45
app/screens/SplashPage.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Text, View } from 'react-native'
|
||||
import RNFS from 'react-native-fs'
|
||||
import paths from '@app/util/paths'
|
||||
|
||||
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 SplashPage: React.FC<{}> = ({ children }) => {
|
||||
const [ready, setReady] = useState(false)
|
||||
|
||||
const minSplashTime = new Promise(resolve => setTimeout(resolve, 1))
|
||||
|
||||
const prepare = async () => {
|
||||
await mkdir(paths.imageCache)
|
||||
await mkdir(paths.songCache)
|
||||
await mkdir(paths.songs)
|
||||
}
|
||||
|
||||
const promise = Promise.all([prepare(), minSplashTime])
|
||||
|
||||
useEffect(() => {
|
||||
promise.then(() => {
|
||||
setReady(true)
|
||||
})
|
||||
})
|
||||
|
||||
if (!ready) {
|
||||
return <Text>Loading THE GOOD SHIT...</Text>
|
||||
}
|
||||
return <View style={{ flex: 1 }}>{children}</View>
|
||||
}
|
||||
|
||||
export default SplashPage
|
||||
28
app/service.ts
Normal file
28
app/service.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
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(() => {})),
|
||||
)
|
||||
}
|
||||
204
app/state/music.ts
Normal file
204
app/state/music.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import { atom, useAtom } from 'jotai'
|
||||
import { atomFamily, useAtomValue, useUpdateAtom } from 'jotai/utils'
|
||||
import { Album, AlbumArt, AlbumWithSongs, Artist, ArtistArt, ArtistInfo, Song } from '@app/models/music'
|
||||
import { SubsonicApiClient } from '@app/subsonic/api'
|
||||
import { AlbumID3Element, ArtistInfo2Element, ChildElement } from '@app/subsonic/elements'
|
||||
import { GetArtistResponse } from '@app/subsonic/responses'
|
||||
import { activeServerAtom } from '@app/state/settings'
|
||||
|
||||
export const artistsAtom = atom<Artist[]>([])
|
||||
export const artistsUpdatingAtom = atom(false)
|
||||
|
||||
export const useUpdateArtists = () => {
|
||||
const server = useAtomValue(activeServerAtom)
|
||||
const [updating, setUpdating] = useAtom(artistsUpdatingAtom)
|
||||
const setArtists = useUpdateAtom(artistsAtom)
|
||||
|
||||
if (!server) {
|
||||
return () => Promise.resolve()
|
||||
}
|
||||
|
||||
return async () => {
|
||||
if (updating) {
|
||||
return
|
||||
}
|
||||
setUpdating(true)
|
||||
|
||||
const client = new SubsonicApiClient(server)
|
||||
const response = await client.getArtists()
|
||||
|
||||
setArtists(
|
||||
response.data.artists.map(x => ({
|
||||
id: x.id,
|
||||
name: x.name,
|
||||
starred: x.starred,
|
||||
})),
|
||||
)
|
||||
setUpdating(false)
|
||||
}
|
||||
}
|
||||
|
||||
export const albumsAtom = atom<Record<string, Album>>({})
|
||||
export const albumsUpdatingAtom = atom(false)
|
||||
|
||||
export const useUpdateAlbums = () => {
|
||||
const server = useAtomValue(activeServerAtom)
|
||||
const [updating, setUpdating] = useAtom(albumsUpdatingAtom)
|
||||
const setAlbums = useUpdateAtom(albumsAtom)
|
||||
|
||||
if (!server) {
|
||||
return () => Promise.resolve()
|
||||
}
|
||||
|
||||
return async () => {
|
||||
if (updating) {
|
||||
return
|
||||
}
|
||||
setUpdating(true)
|
||||
|
||||
const client = new SubsonicApiClient(server)
|
||||
const response = await client.getAlbumList2({ type: 'alphabeticalByArtist', size: 500 })
|
||||
|
||||
setAlbums(
|
||||
response.data.albums.reduce((acc, next) => {
|
||||
const album = mapAlbumID3(next, client)
|
||||
acc[album.id] = album
|
||||
return acc
|
||||
}, {} as Record<string, Album>),
|
||||
)
|
||||
setUpdating(false)
|
||||
}
|
||||
}
|
||||
|
||||
export const albumAtomFamily = atomFamily((id: string) =>
|
||||
atom<AlbumWithSongs | undefined>(async get => {
|
||||
const server = get(activeServerAtom)
|
||||
if (!server) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const client = new SubsonicApiClient(server)
|
||||
const response = await client.getAlbum({ id })
|
||||
return mapAlbumID3WithSongs(response.data.album, response.data.songs, client)
|
||||
}),
|
||||
)
|
||||
|
||||
export const albumArtAtomFamily = atomFamily((id: string) =>
|
||||
atom<AlbumArt | undefined>(async get => {
|
||||
const server = get(activeServerAtom)
|
||||
if (!server) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const albums = get(albumsAtom)
|
||||
const album = id in albums ? albums[id] : undefined
|
||||
if (!album) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const client = new SubsonicApiClient(server)
|
||||
|
||||
return {
|
||||
uri: album.coverArt ? client.getCoverArtUri({ id: album.coverArt }) : undefined,
|
||||
thumbUri: album.coverArt ? client.getCoverArtUri({ id: album.coverArt, size: '256' }) : undefined,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
export const artistInfoAtomFamily = atomFamily((id: string) =>
|
||||
atom<ArtistInfo | undefined>(async get => {
|
||||
const server = get(activeServerAtom)
|
||||
if (!server) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const client = new SubsonicApiClient(server)
|
||||
const [artistResponse, artistInfoResponse] = await Promise.all([
|
||||
client.getArtist({ id }),
|
||||
client.getArtistInfo2({ id }),
|
||||
])
|
||||
return mapArtistInfo(artistResponse.data, artistInfoResponse.data.artistInfo, client)
|
||||
}),
|
||||
)
|
||||
|
||||
export const artistArtAtomFamily = atomFamily((id: string) =>
|
||||
atom<ArtistArt | undefined>(async get => {
|
||||
const artistInfo = get(artistInfoAtomFamily(id))
|
||||
if (!artistInfo) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const coverArtUris = artistInfo.albums
|
||||
.filter(a => a.coverArtThumbUri !== undefined)
|
||||
.sort((a, b) => {
|
||||
if (b.year && a.year) {
|
||||
return b.year - a.year
|
||||
} else {
|
||||
return a.name.localeCompare(b.name)
|
||||
}
|
||||
})
|
||||
.map(a => a.coverArtThumbUri) as string[]
|
||||
|
||||
return {
|
||||
coverArtUris,
|
||||
uri: artistInfo.mediumImageUrl,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
function mapArtistInfo(
|
||||
artistResponse: GetArtistResponse,
|
||||
artistInfo: ArtistInfo2Element,
|
||||
client: SubsonicApiClient,
|
||||
): ArtistInfo {
|
||||
const info = { ...artistInfo } as any
|
||||
delete info.similarArtists
|
||||
|
||||
const { artist, albums } = artistResponse
|
||||
|
||||
const mappedAlbums = albums.map(a => mapAlbumID3(a, client))
|
||||
const coverArtUris = mappedAlbums
|
||||
.sort((a, b) => {
|
||||
if (a.year && b.year) {
|
||||
return a.year - b.year
|
||||
} else {
|
||||
return a.name.localeCompare(b.name) - 9000
|
||||
}
|
||||
})
|
||||
.map(a => a.coverArtThumbUri)
|
||||
|
||||
return {
|
||||
...artist,
|
||||
...info,
|
||||
albums: mappedAlbums,
|
||||
coverArtUris,
|
||||
}
|
||||
}
|
||||
|
||||
function mapAlbumID3(album: AlbumID3Element, client: SubsonicApiClient): Album {
|
||||
return {
|
||||
...album,
|
||||
coverArtUri: album.coverArt ? client.getCoverArtUri({ id: album.coverArt }) : undefined,
|
||||
coverArtThumbUri: album.coverArt ? client.getCoverArtUri({ id: album.coverArt, size: '256' }) : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function mapChildToSong(child: ChildElement, client: SubsonicApiClient): Song {
|
||||
return {
|
||||
...child,
|
||||
streamUri: client.streamUri({ id: child.id }),
|
||||
coverArtUri: child.coverArt ? client.getCoverArtUri({ id: child.coverArt }) : undefined,
|
||||
coverArtThumbUri: child.coverArt ? client.getCoverArtUri({ id: child.coverArt, size: '256' }) : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function mapAlbumID3WithSongs(
|
||||
album: AlbumID3Element,
|
||||
songs: ChildElement[],
|
||||
client: SubsonicApiClient,
|
||||
): AlbumWithSongs {
|
||||
return {
|
||||
...mapAlbumID3(album, client),
|
||||
songs: songs.map(s => mapChildToSong(s, client)),
|
||||
}
|
||||
}
|
||||
12
app/state/settings.ts
Normal file
12
app/state/settings.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { atom } from 'jotai'
|
||||
import { AppSettings } from '@app/models/settings'
|
||||
import atomWithAsyncStorage from '@app/storage/atomWithAsyncStorage'
|
||||
|
||||
export const appSettingsAtom = atomWithAsyncStorage<AppSettings>('@appSettings', {
|
||||
servers: [],
|
||||
})
|
||||
|
||||
export const activeServerAtom = atom(get => {
|
||||
const appSettings = get(appSettingsAtom)
|
||||
return appSettings.servers.find(x => x.id === appSettings.activeServer)
|
||||
})
|
||||
278
app/state/trackplayer.ts
Normal file
278
app/state/trackplayer.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
import equal from 'fast-deep-equal'
|
||||
import { atom } from 'jotai'
|
||||
import { useAtomValue, useUpdateAtom } from 'jotai/utils'
|
||||
import { useEffect } from 'react'
|
||||
import TrackPlayer, { State, Track } from 'react-native-track-player'
|
||||
import { Song } from '@app/models/music'
|
||||
import PromiseQueue from '@app/util/PromiseQueue'
|
||||
|
||||
type TrackExt = Track & {
|
||||
id: string
|
||||
queueName: string
|
||||
artworkThumb?: string
|
||||
}
|
||||
|
||||
type OptionalTrackExt = TrackExt | undefined
|
||||
|
||||
type Progress = {
|
||||
position: number
|
||||
duration: number
|
||||
buffered: number
|
||||
}
|
||||
|
||||
const playerState = atom<State>(State.None)
|
||||
export const playerStateAtom = atom<State, State>(
|
||||
get => get(playerState),
|
||||
(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 queueReadAtom = atom<TrackExt[]>(get => get(_queue))
|
||||
export const queueWriteAtom = atom<TrackExt[], TrackExt[]>(
|
||||
get => get(_queue),
|
||||
(get, set, update) => {
|
||||
if (!equal(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
|
||||
})
|
||||
|
||||
const _progress = atom<Progress>({ position: 0, duration: 0, buffered: 0 })
|
||||
export const progressAtom = atom<Progress, Progress>(
|
||||
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)
|
||||
})
|
||||
|
||||
export const trackPlayerCommands = new PromiseQueue(1)
|
||||
|
||||
const getQueue = async (): Promise<TrackExt[]> => {
|
||||
return ((await TrackPlayer.getQueue()) as TrackExt[]) || []
|
||||
}
|
||||
|
||||
const getTrack = async (index: number): Promise<TrackExt> => {
|
||||
return ((await TrackPlayer.getTrack(index)) as TrackExt) || undefined
|
||||
}
|
||||
|
||||
const getPlayerState = async (): Promise<State> => {
|
||||
return (await TrackPlayer.getState()) || State.None
|
||||
}
|
||||
|
||||
const getProgress = async (): Promise<Progress> => {
|
||||
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(queueWriteAtom)
|
||||
|
||||
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 useAdd = () => {
|
||||
const setQueue = useUpdateAtom(queueWriteAtom)
|
||||
const setCurrentTrack = useUpdateAtom(currentTrackAtom)
|
||||
|
||||
return (tracks: TrackExt | TrackExt[], insertBeforeindex?: number) =>
|
||||
trackPlayerCommands.enqueue(async () => {
|
||||
await TrackPlayer.add(tracks, insertBeforeindex)
|
||||
|
||||
const queue = await getQueue()
|
||||
setQueue(queue)
|
||||
setCurrentTrack(queue.length > 0 ? queue[await TrackPlayer.getCurrentTrack()] : undefined)
|
||||
})
|
||||
}
|
||||
|
||||
export const useReset = (enqueue = true) => {
|
||||
const setQueue = useUpdateAtom(queueWriteAtom)
|
||||
const setCurrentTrack = useUpdateAtom(currentTrackAtom)
|
||||
|
||||
const reset = async () => {
|
||||
await TrackPlayer.reset()
|
||||
setQueue([])
|
||||
setCurrentTrack(undefined)
|
||||
}
|
||||
|
||||
return enqueue ? () => trackPlayerCommands.enqueue(reset) : reset
|
||||
}
|
||||
|
||||
export const useSetQueue = () => {
|
||||
const setCurrentTrack = useUpdateAtom(currentTrackAtom)
|
||||
const setQueue = useUpdateAtom(queueWriteAtom)
|
||||
const reset = useReset(false)
|
||||
|
||||
return async (songs: Song[], name: string, playId?: string) =>
|
||||
trackPlayerCommands.enqueue(async () => {
|
||||
await TrackPlayer.setupPlayer()
|
||||
await 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())
|
||||
})
|
||||
}
|
||||
|
||||
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, queueName: string): TrackExt {
|
||||
return {
|
||||
id: song.id,
|
||||
queueName,
|
||||
title: song.title,
|
||||
artist: song.artist || 'Unknown Artist',
|
||||
album: song.album || 'Unknown Album',
|
||||
url: song.streamUri,
|
||||
artwork: song.coverArtUri,
|
||||
artworkThumb: song.coverArtThumbUri,
|
||||
duration: song.duration,
|
||||
}
|
||||
}
|
||||
54
app/storage/asyncstorage.ts
Normal file
54
app/storage/asyncstorage.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
|
||||
export async function getItem(key: string): Promise<any | null> {
|
||||
try {
|
||||
const item = await AsyncStorage.getItem(key)
|
||||
return item ? JSON.parse(item) : null
|
||||
} catch (e) {
|
||||
console.error(`getItem error (key: ${key})`, e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function multiGet(keys: string[]): Promise<[string, any | null][]> {
|
||||
try {
|
||||
const items = await AsyncStorage.multiGet(keys)
|
||||
return items.map(x => [x[0], x[1] ? JSON.parse(x[1]) : null])
|
||||
} catch (e) {
|
||||
console.error('multiGet error', e)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function setItem(key: string, item: any): Promise<void> {
|
||||
try {
|
||||
await AsyncStorage.setItem(key, JSON.stringify(item))
|
||||
} catch (e) {
|
||||
console.error(`setItem error (key: ${key})`, e)
|
||||
}
|
||||
}
|
||||
|
||||
export async function multiSet(items: string[][]): Promise<void> {
|
||||
try {
|
||||
await AsyncStorage.multiSet(items.map(x => [x[0], JSON.stringify(x[1])]))
|
||||
} catch (e) {
|
||||
console.error('multiSet error', e)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAllKeys(): Promise<string[]> {
|
||||
try {
|
||||
return await AsyncStorage.getAllKeys()
|
||||
} catch (e) {
|
||||
console.error('getAllKeys error', e)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function multiRemove(keys: string[]): Promise<void> {
|
||||
try {
|
||||
await AsyncStorage.multiRemove(keys)
|
||||
} catch (e) {
|
||||
console.error('multiRemove error', e)
|
||||
}
|
||||
}
|
||||
10
app/storage/atomWithAsyncStorage.ts
Normal file
10
app/storage/atomWithAsyncStorage.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { atomWithStorage } from 'jotai/utils'
|
||||
import { getItem, setItem } from '@app/storage/asyncstorage'
|
||||
|
||||
export default <T>(key: string, defaultValue: T) => {
|
||||
return atomWithStorage<T>(key, defaultValue, {
|
||||
getItem: async () => (await getItem(key)) || defaultValue,
|
||||
setItem: setItem,
|
||||
delayInit: true,
|
||||
})
|
||||
}
|
||||
38
app/storage/music.ts
Normal file
38
app/storage/music.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { DownloadedSong } from '@app/models/music'
|
||||
import { getItem, multiGet, multiSet } from '@app/storage/asyncstorage'
|
||||
|
||||
const key = {
|
||||
downloadedSongKeys: '@downloadedSongKeys',
|
||||
downloadedAlbumKeys: '@downloadedAlbumKeys',
|
||||
downloadedArtistKeys: '@downloadedArtistKeys',
|
||||
downloadedPlaylistKeys: '@downloadedPlaylistKeys',
|
||||
}
|
||||
|
||||
export async function getDownloadedSongs(): Promise<DownloadedSong[]> {
|
||||
const keysItem = await getItem(key.downloadedSongKeys)
|
||||
const keys: string[] = keysItem ? JSON.parse(keysItem) : []
|
||||
|
||||
const items = await multiGet(keys)
|
||||
return items.map(x => {
|
||||
const parsed = JSON.parse(x[1] as string)
|
||||
return {
|
||||
id: x[0],
|
||||
type: 'song',
|
||||
...parsed,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function setDownloadedSongs(items: DownloadedSong[]): Promise<void> {
|
||||
await multiSet([
|
||||
[key.downloadedSongKeys, JSON.stringify(items.map(x => x.id))],
|
||||
...items.map(x => [
|
||||
x.id,
|
||||
JSON.stringify({
|
||||
name: x.name,
|
||||
album: x.album,
|
||||
artist: x.artist,
|
||||
}),
|
||||
]),
|
||||
])
|
||||
}
|
||||
12
app/styles/colors.ts
Normal file
12
app/styles/colors.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export default {
|
||||
text: {
|
||||
primary: '#ffffff',
|
||||
secondary: '#999999',
|
||||
},
|
||||
gradient: {
|
||||
high: '#2d2d2d',
|
||||
low: '#000000',
|
||||
},
|
||||
accent: '#b134db',
|
||||
accentLow: '#511c63',
|
||||
}
|
||||
73
app/styles/text.ts
Normal file
73
app/styles/text.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { TextStyle } from 'react-native'
|
||||
import colors from '@app/styles/colors'
|
||||
|
||||
export enum Font {
|
||||
regular = 'Metropolis-Regular',
|
||||
semiBold = 'Metropolis-SemiBold',
|
||||
bold = 'Metropolis-Bold',
|
||||
}
|
||||
|
||||
const paragraph: TextStyle = {
|
||||
fontFamily: Font.regular,
|
||||
fontSize: 16,
|
||||
color: colors.text.primary,
|
||||
}
|
||||
|
||||
const header: TextStyle = {
|
||||
...paragraph,
|
||||
fontSize: 18,
|
||||
fontFamily: Font.semiBold,
|
||||
}
|
||||
|
||||
const title: TextStyle = {
|
||||
...paragraph,
|
||||
fontSize: 24,
|
||||
fontFamily: Font.bold,
|
||||
}
|
||||
|
||||
const itemTitle: TextStyle = {
|
||||
...paragraph,
|
||||
fontSize: 13,
|
||||
fontFamily: Font.semiBold,
|
||||
}
|
||||
|
||||
const itemSubtitle: TextStyle = {
|
||||
...paragraph,
|
||||
fontSize: 12,
|
||||
color: colors.text.secondary,
|
||||
}
|
||||
|
||||
const songListTitle: TextStyle = {
|
||||
...paragraph,
|
||||
fontSize: 16,
|
||||
fontFamily: Font.semiBold,
|
||||
}
|
||||
|
||||
const songListSubtitle: TextStyle = {
|
||||
...paragraph,
|
||||
fontSize: 14,
|
||||
color: colors.text.secondary,
|
||||
}
|
||||
|
||||
const xsmall: TextStyle = {
|
||||
...paragraph,
|
||||
fontSize: 10,
|
||||
}
|
||||
|
||||
const button: TextStyle = {
|
||||
...paragraph,
|
||||
fontSize: 15,
|
||||
fontFamily: Font.bold,
|
||||
}
|
||||
|
||||
export default {
|
||||
paragraph,
|
||||
header,
|
||||
title,
|
||||
itemTitle,
|
||||
itemSubtitle,
|
||||
songListTitle,
|
||||
songListSubtitle,
|
||||
xsmall,
|
||||
button,
|
||||
}
|
||||
198
app/subsonic/api.ts
Normal file
198
app/subsonic/api.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import { DOMParser } from 'xmldom'
|
||||
import RNFS from 'react-native-fs'
|
||||
import {
|
||||
GetAlbumList2Params,
|
||||
GetAlbumListParams,
|
||||
GetAlbumParams,
|
||||
GetArtistInfo2Params,
|
||||
GetArtistInfoParams,
|
||||
GetArtistParams,
|
||||
GetCoverArtParams,
|
||||
GetIndexesParams,
|
||||
GetMusicDirectoryParams,
|
||||
StreamParams,
|
||||
} from '@app/subsonic/params'
|
||||
import {
|
||||
GetAlbumList2Response,
|
||||
GetAlbumListResponse,
|
||||
GetAlbumResponse,
|
||||
GetArtistInfo2Response,
|
||||
GetArtistInfoResponse,
|
||||
GetArtistResponse,
|
||||
GetArtistsResponse,
|
||||
GetIndexesResponse,
|
||||
GetMusicDirectoryResponse,
|
||||
SubsonicResponse,
|
||||
} from '@app/subsonic/responses'
|
||||
import { Server } from '@app/models/settings'
|
||||
import paths from '@app/util/paths'
|
||||
import PromiseQueue from '@app/util/PromiseQueue'
|
||||
|
||||
export class SubsonicApiError extends Error {
|
||||
method: string
|
||||
code: string
|
||||
|
||||
constructor(method: string, xml: Document) {
|
||||
const errorElement = xml.getElementsByTagName('error')[0]
|
||||
|
||||
super(errorElement.getAttribute('message') as string)
|
||||
|
||||
this.name = method
|
||||
this.method = method
|
||||
this.code = errorElement.getAttribute('code') as string
|
||||
}
|
||||
}
|
||||
|
||||
const downloadQueue = new PromiseQueue(1)
|
||||
|
||||
export class SubsonicApiClient {
|
||||
address: string
|
||||
username: string
|
||||
|
||||
private params: URLSearchParams
|
||||
|
||||
constructor(server: Server) {
|
||||
this.address = server.address
|
||||
this.username = server.username
|
||||
|
||||
this.params = new URLSearchParams()
|
||||
this.params.append('u', server.username)
|
||||
this.params.append('t', server.token)
|
||||
this.params.append('s', server.salt)
|
||||
this.params.append('v', '1.15.0')
|
||||
this.params.append('c', 'subsonify-cool-unique-app-string')
|
||||
}
|
||||
|
||||
private buildUrl(method: string, params?: { [key: string]: any }): string {
|
||||
let query = this.params.toString()
|
||||
if (params) {
|
||||
const urlParams = this.obj2Params(params)
|
||||
if (urlParams) {
|
||||
query += '&' + urlParams.toString()
|
||||
}
|
||||
}
|
||||
|
||||
const url = `${this.address}/rest/${method}?${query}`
|
||||
// console.log(url);
|
||||
return url
|
||||
}
|
||||
|
||||
private async apiDownload(method: string, path: string, params?: { [key: string]: any }): Promise<string> {
|
||||
const download = RNFS.downloadFile({
|
||||
fromUrl: this.buildUrl(method, params),
|
||||
toFile: path,
|
||||
}).promise
|
||||
|
||||
await downloadQueue.enqueue(() => download)
|
||||
await downloadQueue.enqueue(() => new Promise(resolve => setTimeout(resolve, 100)))
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
private async apiGetXml(method: string, params?: { [key: string]: any }): Promise<Document> {
|
||||
const response = await fetch(this.buildUrl(method, params))
|
||||
const text = await response.text()
|
||||
|
||||
// console.log(text);
|
||||
|
||||
const xml = new DOMParser().parseFromString(text)
|
||||
if (xml.documentElement.getAttribute('status') !== 'ok') {
|
||||
throw new SubsonicApiError(method, xml)
|
||||
}
|
||||
|
||||
return xml
|
||||
}
|
||||
|
||||
private obj2Params(obj: { [key: string]: any }): URLSearchParams | undefined {
|
||||
const keys = Object.keys(obj)
|
||||
if (keys.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const params = new URLSearchParams()
|
||||
for (const key of keys) {
|
||||
params.append(key, String(obj[key]))
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
//
|
||||
// System
|
||||
//
|
||||
|
||||
async ping(): Promise<SubsonicResponse<null>> {
|
||||
const xml = await this.apiGetXml('ping')
|
||||
return new SubsonicResponse<null>(xml, null)
|
||||
}
|
||||
|
||||
//
|
||||
// Browsing
|
||||
//
|
||||
|
||||
async getArtists(): Promise<SubsonicResponse<GetArtistsResponse>> {
|
||||
const xml = await this.apiGetXml('getArtists')
|
||||
return new SubsonicResponse<GetArtistsResponse>(xml, new GetArtistsResponse(xml))
|
||||
}
|
||||
|
||||
async getIndexes(params?: GetIndexesParams): Promise<SubsonicResponse<GetIndexesResponse>> {
|
||||
const xml = await this.apiGetXml('getIndexes', params)
|
||||
return new SubsonicResponse<GetIndexesResponse>(xml, new GetIndexesResponse(xml))
|
||||
}
|
||||
|
||||
async getMusicDirectory(params: GetMusicDirectoryParams): Promise<SubsonicResponse<GetMusicDirectoryResponse>> {
|
||||
const xml = await this.apiGetXml('getMusicDirectory', params)
|
||||
return new SubsonicResponse<GetMusicDirectoryResponse>(xml, new GetMusicDirectoryResponse(xml))
|
||||
}
|
||||
|
||||
async getAlbum(params: GetAlbumParams): Promise<SubsonicResponse<GetAlbumResponse>> {
|
||||
const xml = await this.apiGetXml('getAlbum', params)
|
||||
return new SubsonicResponse<GetAlbumResponse>(xml, new GetAlbumResponse(xml))
|
||||
}
|
||||
|
||||
async getArtistInfo(params: GetArtistInfoParams): Promise<SubsonicResponse<GetArtistInfoResponse>> {
|
||||
const xml = await this.apiGetXml('getArtistInfo', params)
|
||||
return new SubsonicResponse<GetArtistInfoResponse>(xml, new GetArtistInfoResponse(xml))
|
||||
}
|
||||
|
||||
async getArtistInfo2(params: GetArtistInfo2Params): Promise<SubsonicResponse<GetArtistInfo2Response>> {
|
||||
const xml = await this.apiGetXml('getArtistInfo2', params)
|
||||
return new SubsonicResponse<GetArtistInfo2Response>(xml, new GetArtistInfo2Response(xml))
|
||||
}
|
||||
|
||||
async getArtist(params: GetArtistParams): Promise<SubsonicResponse<GetArtistResponse>> {
|
||||
const xml = await this.apiGetXml('getArtist', params)
|
||||
return new SubsonicResponse<GetArtistResponse>(xml, new GetArtistResponse(xml))
|
||||
}
|
||||
|
||||
//
|
||||
// Album/song lists
|
||||
//
|
||||
|
||||
async getAlbumList(params: GetAlbumListParams): Promise<SubsonicResponse<GetAlbumListResponse>> {
|
||||
const xml = await this.apiGetXml('getAlbumList', params)
|
||||
return new SubsonicResponse<GetAlbumListResponse>(xml, new GetAlbumListResponse(xml))
|
||||
}
|
||||
|
||||
async getAlbumList2(params: GetAlbumList2Params): Promise<SubsonicResponse<GetAlbumList2Response>> {
|
||||
const xml = await this.apiGetXml('getAlbumList2', params)
|
||||
return new SubsonicResponse<GetAlbumList2Response>(xml, new GetAlbumList2Response(xml))
|
||||
}
|
||||
|
||||
//
|
||||
// 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 {
|
||||
return this.buildUrl('getCoverArt', params)
|
||||
}
|
||||
|
||||
streamUri(params: StreamParams): string {
|
||||
return this.buildUrl('stream', params)
|
||||
}
|
||||
}
|
||||
237
app/subsonic/elements.ts
Normal file
237
app/subsonic/elements.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
function requiredString(e: Element, name: string): string {
|
||||
return e.getAttribute(name) as string
|
||||
}
|
||||
|
||||
function optionalString(e: Element, name: string): string | undefined {
|
||||
return e.hasAttribute(name) ? requiredString(e, name) : undefined
|
||||
}
|
||||
|
||||
function requiredBoolean(e: Element, name: string): boolean {
|
||||
return (e.getAttribute(name) as string).toLowerCase() === 'true'
|
||||
}
|
||||
|
||||
function optionalBoolean(e: Element, name: string): boolean | undefined {
|
||||
return e.hasAttribute(name) ? requiredBoolean(e, name) : undefined
|
||||
}
|
||||
|
||||
function requiredInt(e: Element, name: string): number {
|
||||
return parseInt(e.getAttribute(name) as string)
|
||||
}
|
||||
|
||||
function optionalInt(e: Element, name: string): number | undefined {
|
||||
return e.hasAttribute(name) ? requiredInt(e, name) : undefined
|
||||
}
|
||||
|
||||
function requiredFloat(e: Element, name: string): number {
|
||||
return parseFloat(e.getAttribute(name) as string)
|
||||
}
|
||||
|
||||
function optionalFloat(e: Element, name: string): number | undefined {
|
||||
return e.hasAttribute(name) ? requiredFloat(e, name) : undefined
|
||||
}
|
||||
|
||||
function requiredDate(e: Element, name: string): Date {
|
||||
return new Date(e.getAttribute(name) as string)
|
||||
}
|
||||
|
||||
function optionalDate(e: Element, name: string): Date | undefined {
|
||||
return e.hasAttribute(name) ? requiredDate(e, name) : undefined
|
||||
}
|
||||
|
||||
export class BaseArtistElement {
|
||||
id: string
|
||||
name: string
|
||||
starred?: Date
|
||||
|
||||
constructor(e: Element) {
|
||||
this.id = requiredString(e, 'id')
|
||||
this.name = requiredString(e, 'name')
|
||||
this.starred = optionalDate(e, 'starred')
|
||||
}
|
||||
}
|
||||
|
||||
export class ArtistID3Element extends BaseArtistElement {
|
||||
coverArt?: string
|
||||
albumCount?: number
|
||||
|
||||
constructor(e: Element) {
|
||||
super(e)
|
||||
this.coverArt = optionalString(e, 'coverArt')
|
||||
this.albumCount = optionalInt(e, 'albumCount')
|
||||
}
|
||||
}
|
||||
|
||||
export class ArtistElement extends BaseArtistElement {
|
||||
userRating?: number
|
||||
averageRating?: number
|
||||
|
||||
constructor(e: Element) {
|
||||
super(e)
|
||||
this.userRating = optionalInt(e, 'userRating')
|
||||
this.averageRating = optionalFloat(e, 'averageRating')
|
||||
}
|
||||
}
|
||||
|
||||
export class BaseArtistInfoElement<T> {
|
||||
similarArtists: T[] = []
|
||||
biography?: string
|
||||
musicBrainzId?: string
|
||||
lastFmUrl?: string
|
||||
smallImageUrl?: string
|
||||
mediumImageUrl?: string
|
||||
largeImageUrl?: string
|
||||
|
||||
constructor(e: Element, artistType: new (e: Element) => T) {
|
||||
if (e.getElementsByTagName('biography').length > 0) {
|
||||
this.biography = e.getElementsByTagName('biography')[0].textContent as string
|
||||
}
|
||||
if (e.getElementsByTagName('musicBrainzId').length > 0) {
|
||||
this.musicBrainzId = e.getElementsByTagName('musicBrainzId')[0].textContent as string
|
||||
}
|
||||
if (e.getElementsByTagName('lastFmUrl').length > 0) {
|
||||
this.lastFmUrl = e.getElementsByTagName('lastFmUrl')[0].textContent as string
|
||||
}
|
||||
if (e.getElementsByTagName('smallImageUrl').length > 0) {
|
||||
this.smallImageUrl = e.getElementsByTagName('smallImageUrl')[0].textContent as string
|
||||
}
|
||||
if (e.getElementsByTagName('mediumImageUrl').length > 0) {
|
||||
this.mediumImageUrl = e.getElementsByTagName('mediumImageUrl')[0].textContent as string
|
||||
}
|
||||
if (e.getElementsByTagName('largeImageUrl').length > 0) {
|
||||
this.largeImageUrl = e.getElementsByTagName('largeImageUrl')[0].textContent as string
|
||||
}
|
||||
|
||||
const similarArtistElements = e.getElementsByTagName('similarArtist')
|
||||
for (let i = 0; i < similarArtistElements.length; i++) {
|
||||
this.similarArtists.push(new artistType(similarArtistElements[i]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class ArtistInfoElement extends BaseArtistInfoElement<ArtistElement> {
|
||||
constructor(e: Element) {
|
||||
super(e, ArtistElement)
|
||||
}
|
||||
}
|
||||
export class ArtistInfo2Element extends BaseArtistInfoElement<ArtistID3Element> {
|
||||
constructor(e: Element) {
|
||||
super(e, ArtistID3Element)
|
||||
}
|
||||
}
|
||||
|
||||
export class DirectoryElement {
|
||||
id: string
|
||||
parent?: string
|
||||
name: string
|
||||
starred?: Date
|
||||
userRating?: number
|
||||
averageRating?: number
|
||||
playCount?: number
|
||||
|
||||
constructor(e: Element) {
|
||||
this.id = requiredString(e, 'id')
|
||||
this.parent = optionalString(e, 'parent')
|
||||
this.name = requiredString(e, 'name')
|
||||
this.starred = optionalDate(e, 'starred')
|
||||
this.userRating = optionalInt(e, 'userRating')
|
||||
this.averageRating = optionalFloat(e, 'averageRating')
|
||||
}
|
||||
}
|
||||
|
||||
export class ChildElement {
|
||||
id: string
|
||||
parent?: string
|
||||
isDir: boolean
|
||||
title: string
|
||||
album?: string
|
||||
artist?: string
|
||||
track?: number
|
||||
year?: number
|
||||
genre?: string
|
||||
coverArt?: string
|
||||
size?: number
|
||||
contentType?: string
|
||||
suffix?: string
|
||||
transcodedContentType?: string
|
||||
transcodedSuffix?: string
|
||||
duration?: number
|
||||
bitRate?: number
|
||||
path?: string
|
||||
isVideo?: boolean
|
||||
userRating?: number
|
||||
averageRating?: number
|
||||
playCount?: number
|
||||
discNumber?: number
|
||||
created?: Date
|
||||
starred?: Date
|
||||
albumId?: string
|
||||
artistId?: string
|
||||
type?: string
|
||||
bookmarkPosition?: number
|
||||
originalWidth?: number
|
||||
originalHeight?: number
|
||||
|
||||
constructor(e: Element) {
|
||||
this.id = requiredString(e, 'id')
|
||||
this.parent = optionalString(e, 'parent')
|
||||
this.isDir = requiredBoolean(e, 'isDir')
|
||||
this.title = requiredString(e, 'title')
|
||||
this.album = optionalString(e, 'album')
|
||||
this.artist = optionalString(e, 'artist')
|
||||
this.track = optionalInt(e, 'track')
|
||||
this.year = optionalInt(e, 'year')
|
||||
this.genre = optionalString(e, 'genre')
|
||||
this.coverArt = optionalString(e, 'coverArt')
|
||||
this.size = optionalInt(e, 'size')
|
||||
this.contentType = optionalString(e, 'contentType')
|
||||
this.suffix = optionalString(e, 'suffix')
|
||||
this.transcodedContentType = optionalString(e, 'transcodedContentType')
|
||||
this.transcodedSuffix = optionalString(e, 'transcodedSuffix')
|
||||
this.duration = optionalInt(e, 'duration')
|
||||
this.bitRate = optionalInt(e, 'bitRate')
|
||||
this.path = optionalString(e, 'path')
|
||||
this.isVideo = optionalBoolean(e, 'isVideo')
|
||||
this.userRating = optionalInt(e, 'userRating')
|
||||
this.averageRating = optionalFloat(e, 'averageRating')
|
||||
this.playCount = optionalInt(e, 'playCount')
|
||||
this.discNumber = optionalInt(e, 'discNumber')
|
||||
this.created = optionalDate(e, 'created')
|
||||
this.starred = optionalDate(e, 'starred')
|
||||
this.albumId = optionalString(e, 'albumId')
|
||||
this.artistId = optionalString(e, 'artistId')
|
||||
this.type = optionalString(e, 'type')
|
||||
this.bookmarkPosition = optionalInt(e, 'bookmarkPosition')
|
||||
this.originalWidth = optionalInt(e, 'originalWidth')
|
||||
this.originalHeight = optionalInt(e, 'originalHeight')
|
||||
}
|
||||
}
|
||||
|
||||
export class AlbumID3Element {
|
||||
id: string
|
||||
name: string
|
||||
artist?: string
|
||||
artistId?: string
|
||||
coverArt?: string
|
||||
songCount: number
|
||||
duration: number
|
||||
playCount?: number
|
||||
created: Date
|
||||
starred?: Date
|
||||
year?: number
|
||||
genre?: string
|
||||
|
||||
constructor(e: Element) {
|
||||
this.id = requiredString(e, 'id')
|
||||
this.name = requiredString(e, 'name')
|
||||
this.artist = optionalString(e, 'artist')
|
||||
this.artistId = optionalString(e, 'artistId')
|
||||
this.coverArt = optionalString(e, 'coverArt')
|
||||
this.songCount = requiredInt(e, 'songCount')
|
||||
this.duration = requiredInt(e, 'duration')
|
||||
this.playCount = optionalInt(e, 'playCount')
|
||||
this.created = requiredDate(e, 'created')
|
||||
this.starred = optionalDate(e, 'starred')
|
||||
this.year = optionalInt(e, 'year')
|
||||
this.genre = optionalString(e, 'genre')
|
||||
}
|
||||
}
|
||||
14
app/subsonic/hooks.ts
Normal file
14
app/subsonic/hooks.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
84
app/subsonic/params.ts
Normal file
84
app/subsonic/params.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
//
|
||||
// Browsing
|
||||
//
|
||||
|
||||
export type GetIndexesParams = {
|
||||
musicFolderId?: string
|
||||
ifModifiedSince?: number
|
||||
}
|
||||
|
||||
export type GetArtistInfoParams = {
|
||||
id: string
|
||||
count?: number
|
||||
includeNotPresent?: boolean
|
||||
}
|
||||
|
||||
export type GetArtistInfo2Params = GetArtistInfoParams
|
||||
|
||||
export type GetMusicDirectoryParams = {
|
||||
id: string
|
||||
}
|
||||
|
||||
export type GetAlbumParams = {
|
||||
id: string
|
||||
}
|
||||
|
||||
export type GetArtistParams = {
|
||||
id: string
|
||||
}
|
||||
|
||||
//
|
||||
// Album/song lists
|
||||
//
|
||||
|
||||
export type GetAlbumList2Type =
|
||||
| 'random'
|
||||
| 'newest'
|
||||
| 'frequent'
|
||||
| 'recent'
|
||||
| 'starred'
|
||||
| 'alphabeticalByName'
|
||||
| 'alphabeticalByArtist'
|
||||
export type GetAlbumListType = GetAlbumList2Type | ' highest'
|
||||
|
||||
export type GetAlbumList2TypeByYear = {
|
||||
type: 'byYear'
|
||||
fromYear: string
|
||||
toYear: string
|
||||
}
|
||||
|
||||
export type GetAlbumList2TypeByGenre = {
|
||||
type: 'byGenre'
|
||||
genre: string
|
||||
}
|
||||
|
||||
export type GetAlbumList2Params =
|
||||
| {
|
||||
type: GetAlbumList2Type
|
||||
size?: number
|
||||
offset?: number
|
||||
fromYear?: string
|
||||
toYear?: string
|
||||
genre?: string
|
||||
musicFolderId?: string
|
||||
}
|
||||
| GetAlbumList2TypeByYear
|
||||
| GetAlbumList2TypeByGenre
|
||||
|
||||
export type GetAlbumListParams = GetAlbumList2Params
|
||||
|
||||
//
|
||||
// Media retrieval
|
||||
//
|
||||
|
||||
export type GetCoverArtParams = {
|
||||
id: string
|
||||
size?: string
|
||||
}
|
||||
|
||||
export type StreamParams = {
|
||||
id: string
|
||||
maxBitRate?: number
|
||||
format?: string
|
||||
estimateContentLength?: boolean
|
||||
}
|
||||
144
app/subsonic/responses.ts
Normal file
144
app/subsonic/responses.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import {
|
||||
AlbumID3Element,
|
||||
ArtistElement,
|
||||
ArtistID3Element,
|
||||
ArtistInfo2Element,
|
||||
ArtistInfoElement,
|
||||
ChildElement,
|
||||
DirectoryElement,
|
||||
} from '@app/subsonic/elements'
|
||||
|
||||
export type ResponseStatus = 'ok' | 'failed'
|
||||
|
||||
export class SubsonicResponse<T> {
|
||||
status: ResponseStatus
|
||||
version: string
|
||||
data: T
|
||||
|
||||
constructor(xml: Document, data: T) {
|
||||
this.data = data
|
||||
this.status = xml.documentElement.getAttribute('status') as ResponseStatus
|
||||
this.version = xml.documentElement.getAttribute('version') as string
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Browsing
|
||||
//
|
||||
|
||||
export class GetArtistsResponse {
|
||||
ignoredArticles: string
|
||||
artists: ArtistID3Element[] = []
|
||||
|
||||
constructor(xml: Document) {
|
||||
this.ignoredArticles = xml.getElementsByTagName('artists')[0].getAttribute('ignoredArticles') as string
|
||||
|
||||
const artistElements = xml.getElementsByTagName('artist')
|
||||
for (let i = 0; i < artistElements.length; i++) {
|
||||
this.artists.push(new ArtistID3Element(artistElements[i]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class GetArtistResponse {
|
||||
artist: ArtistID3Element
|
||||
albums: AlbumID3Element[] = []
|
||||
|
||||
constructor(xml: Document) {
|
||||
this.artist = new ArtistID3Element(xml.getElementsByTagName('artist')[0])
|
||||
|
||||
const albumElements = xml.getElementsByTagName('album')
|
||||
for (let i = 0; i < albumElements.length; i++) {
|
||||
this.albums.push(new AlbumID3Element(albumElements[i]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class GetIndexesResponse {
|
||||
ignoredArticles: string
|
||||
lastModified: number
|
||||
artists: ArtistElement[] = []
|
||||
|
||||
constructor(xml: Document) {
|
||||
const indexesElement = xml.getElementsByTagName('indexes')[0]
|
||||
|
||||
this.ignoredArticles = indexesElement.getAttribute('ignoredArticles') as string
|
||||
this.lastModified = parseInt(indexesElement.getAttribute('lastModified') as string)
|
||||
|
||||
const artistElements = xml.getElementsByTagName('artist')
|
||||
for (let i = 0; i < artistElements.length; i++) {
|
||||
this.artists.push(new ArtistElement(artistElements[i]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class GetArtistInfoResponse {
|
||||
artistInfo: ArtistInfoElement
|
||||
|
||||
constructor(xml: Document) {
|
||||
this.artistInfo = new ArtistInfoElement(xml.getElementsByTagName('artistInfo')[0])
|
||||
}
|
||||
}
|
||||
|
||||
export class GetArtistInfo2Response {
|
||||
artistInfo: ArtistInfo2Element
|
||||
|
||||
constructor(xml: Document) {
|
||||
this.artistInfo = new ArtistInfo2Element(xml.getElementsByTagName('artistInfo2')[0])
|
||||
}
|
||||
}
|
||||
|
||||
export class GetMusicDirectoryResponse {
|
||||
directory: DirectoryElement
|
||||
children: ChildElement[] = []
|
||||
|
||||
constructor(xml: Document) {
|
||||
this.directory = new DirectoryElement(xml.getElementsByTagName('directory')[0])
|
||||
|
||||
const childElements = xml.getElementsByTagName('child')
|
||||
for (let i = 0; i < childElements.length; i++) {
|
||||
this.children.push(new ChildElement(childElements[i]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class GetAlbumResponse {
|
||||
album: AlbumID3Element
|
||||
songs: ChildElement[] = []
|
||||
|
||||
constructor(xml: Document) {
|
||||
this.album = new AlbumID3Element(xml.getElementsByTagName('album')[0])
|
||||
|
||||
const childElements = xml.getElementsByTagName('song')
|
||||
for (let i = 0; i < childElements.length; i++) {
|
||||
this.songs.push(new ChildElement(childElements[i]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Album/song lists
|
||||
//
|
||||
|
||||
class BaseGetAlbumListResponse<T> {
|
||||
albums: T[] = []
|
||||
|
||||
constructor(xml: Document, albumType: new (e: Element) => T) {
|
||||
const albumElements = xml.getElementsByTagName('album')
|
||||
for (let i = 0; i < albumElements.length; i++) {
|
||||
this.albums.push(new albumType(albumElements[i]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class GetAlbumListResponse extends BaseGetAlbumListResponse<ChildElement> {
|
||||
constructor(xml: Document) {
|
||||
super(xml, ChildElement)
|
||||
}
|
||||
}
|
||||
|
||||
export class GetAlbumList2Response extends BaseGetAlbumListResponse<AlbumID3Element> {
|
||||
constructor(xml: Document) {
|
||||
super(xml, AlbumID3Element)
|
||||
}
|
||||
}
|
||||
31
app/util/PromiseQueue.ts
Normal file
31
app/util/PromiseQueue.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
type QueuedPromise = () => Promise<any>
|
||||
|
||||
class PromiseQueue {
|
||||
maxSimultaneously: number
|
||||
|
||||
private active = 0
|
||||
private queue: QueuedPromise[] = []
|
||||
|
||||
constructor(maxSimultaneously = 1) {
|
||||
this.maxSimultaneously = maxSimultaneously
|
||||
}
|
||||
|
||||
async enqueue<T>(func: () => Promise<T>) {
|
||||
if (++this.active > this.maxSimultaneously) {
|
||||
await new Promise(resolve => this.queue.push(resolve as QueuedPromise))
|
||||
}
|
||||
|
||||
try {
|
||||
return await func()
|
||||
} catch (err) {
|
||||
throw err
|
||||
} finally {
|
||||
this.active--
|
||||
if (this.queue.length) {
|
||||
this.queue.shift()?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default PromiseQueue
|
||||
13
app/util/formatDuration.ts
Normal file
13
app/util/formatDuration.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
function formatDuration(seconds: number): string {
|
||||
const s = Math.floor(seconds) % 60
|
||||
const m = Math.floor(seconds / 60) % 60
|
||||
const h = Math.floor(seconds / 60 / 60)
|
||||
|
||||
let time = `${m.toString().padStart(1, '0')}:${s.toString().padStart(2, '0')}`
|
||||
if (h > 0) {
|
||||
time = `${h}:${time}`
|
||||
}
|
||||
return time
|
||||
}
|
||||
|
||||
export default formatDuration
|
||||
7
app/util/paths.ts
Normal file
7
app/util/paths.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import RNFS from 'react-native-fs'
|
||||
|
||||
export default {
|
||||
imageCache: `${RNFS.DocumentDirectoryPath}/image_cache`,
|
||||
songCache: `${RNFS.DocumentDirectoryPath}/song_cache`,
|
||||
songs: `${RNFS.DocumentDirectoryPath}/songs`,
|
||||
}
|
||||
Reference in New Issue
Block a user