This commit is contained in:
austinried
2023-04-28 12:26:02 +09:00
parent af6fbb77e2
commit eaecda6aab
402 changed files with 34368 additions and 62769 deletions
-47
View File
@@ -1,47 +0,0 @@
import ProgressHook from '@app/components/ProgressHook'
import RootNavigator from '@app/navigation/RootNavigator'
import queryClient from '@app/query/queryClient'
import SplashPage from '@app/screens/SplashPage'
import { useStore } from '@app/state/store'
import colors from '@app/styles/colors'
import React from 'react'
import { StatusBar, StyleSheet, View } from 'react-native'
import { MenuProvider } from 'react-native-popup-menu'
import { QueryClientProvider } from 'react-query'
const Debug = () => {
const currentTrackTitle = useStore(store => store.currentTrack?.title)
console.log('currentTrackTitle', currentTrackTitle)
return <></>
}
const App = () => {
return (
<QueryClientProvider client={queryClient}>
<MenuProvider backHandler={true}>
<View style={styles.appContainer}>
<StatusBar
animated={true}
backgroundColor={'rgba(0, 0, 0, 0.3)'}
barStyle={'light-content'}
translucent={true}
/>
<SplashPage>
<ProgressHook />
<Debug />
<RootNavigator />
</SplashPage>
</View>
</MenuProvider>
</QueryClientProvider>
)
}
const styles = StyleSheet.create({
appContainer: {
flex: 1,
backgroundColor: colors.gradient.high,
},
})
export default App
-4
View File
@@ -1,4 +0,0 @@
{
"name": "subtracks",
"displayName": "Subtracks"
}
-57
View File
@@ -1,57 +0,0 @@
import colors from '@app/styles/colors'
import font from '@app/styles/font'
import React from 'react'
import { GestureResponderEvent, StyleProp, StyleSheet, Text, ViewStyle } from 'react-native'
import PressableOpacity from './PressableOpacity'
const Button: React.FC<{
title?: string
buttonStyle?: 'hollow' | 'highlight'
onPress: (event: GestureResponderEvent) => void
style?: StyleProp<ViewStyle>
disabled?: boolean
}> = ({ title, buttonStyle, onPress, children, style, disabled }) => {
return (
<PressableOpacity
onPress={onPress}
disabled={disabled}
style={[styles.container, buttonStyle !== undefined ? styles[buttonStyle] : {}, style]}>
{title ? (
<Text style={styles.text} numberOfLines={2} adjustsFontSizeToFit={true}>
{title}
</Text>
) : (
children
)}
</PressableOpacity>
)
}
const styles = StyleSheet.create({
container: {
backgroundColor: colors.accent,
paddingHorizontal: 10,
minHeight: 42,
maxHeight: 42,
justifyContent: 'center',
borderRadius: 1000,
},
hollow: {
backgroundColor: 'transparent',
borderColor: colors.text.primary,
borderWidth: 1.5,
},
highlight: {
borderColor: colors.text.primary,
borderWidth: 1.5,
},
text: {
fontSize: 16,
fontFamily: font.bold,
color: colors.text.primary,
paddingHorizontal: 14,
textAlign: 'center',
},
})
export default Button
-403
View File
@@ -1,403 +0,0 @@
import PressableOpacity from '@app/components/PressableOpacity'
import { useStar } from '@app/hooks/query'
import { StarrableItemType, Song, Artist, Album } from '@app/models/library'
import colors from '@app/styles/colors'
import font from '@app/styles/font'
import { NavigationProp, useNavigation } from '@react-navigation/native'
import { ReactComponentLike } from 'prop-types'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { ScrollView, StyleProp, StyleSheet, Text, View, ViewStyle } from 'react-native'
import { Menu, MenuOption, MenuOptions, MenuTrigger, renderers } from 'react-native-popup-menu'
import IconFA from 'react-native-vector-icons/FontAwesome'
import IconFA5 from 'react-native-vector-icons/FontAwesome5'
import CoverArt from './CoverArt'
import { Star } from './Star'
import { withSuspenseMemo } from './withSuspense'
const { SlideInMenu } = renderers
type ContextMenuProps = {
menuStyle?: StyleProp<ViewStyle>
triggerWrapperStyle?: StyleProp<ViewStyle>
triggerOuterWrapperStyle?: StyleProp<ViewStyle>
triggerTouchableStyle?: StyleProp<ViewStyle>
onPress?: () => any
triggerOnLongPress?: boolean
disabled?: boolean
}
type InternalContextMenuProps = ContextMenuProps & {
menuHeader: React.ReactNode
menuOptions: React.ReactNode
}
const ContextMenu: React.FC<InternalContextMenuProps> = ({
menuStyle,
triggerWrapperStyle,
triggerOuterWrapperStyle,
triggerTouchableStyle,
onPress,
menuHeader,
menuOptions,
children,
triggerOnLongPress,
disabled,
}) => {
menuStyle = menuStyle || { flex: 1 }
triggerWrapperStyle = triggerWrapperStyle || { flex: 1 }
triggerOuterWrapperStyle = triggerOuterWrapperStyle || { flex: 1 }
triggerTouchableStyle = triggerTouchableStyle || { flex: 1 }
return (
<Menu renderer={SlideInMenu} style={menuStyle}>
<MenuTrigger
disabled={disabled}
triggerOnLongPress={triggerOnLongPress === undefined ? true : triggerOnLongPress}
customStyles={{
triggerOuterWrapper: triggerOuterWrapperStyle,
triggerWrapper: triggerWrapperStyle,
triggerTouchable: { style: triggerTouchableStyle, disabled },
TriggerTouchableComponent: PressableOpacity,
}}
onAlternativeAction={onPress}>
{children}
</MenuTrigger>
<MenuOptions
customStyles={styles}
renderOptionsContainer={(options: any) => (
<ScrollView>
{menuHeader}
{options}
</ScrollView>
)}>
{menuOptions}
</MenuOptions>
</Menu>
)
}
type ContextMenuOptionProps = {
onSelect?: () => any
}
const ContextMenuOption: React.FC<ContextMenuOptionProps> = ({ onSelect, children }) => (
<MenuOption style={styles.option} onSelect={onSelect}>
{children}
</MenuOption>
)
type ContextMenuIconTextOptionProps = ContextMenuOptionProps & {
IconComponent?: ReactComponentLike
IconComponentRaw?: React.ReactNode
name?: string
size?: number
color?: string
text: string
}
const ContextMenuIconTextOption = React.memo<ContextMenuIconTextOptionProps>(
({ onSelect, IconComponent, IconComponentRaw, name, color, size, text }) => {
let Icon: React.ReactNode
if (IconComponentRaw) {
Icon = IconComponentRaw
} else if (IconComponent) {
Icon = <IconComponent name={name} size={size} color={color || colors.text.primary} />
} else {
Icon = <></>
}
return (
<ContextMenuOption onSelect={onSelect}>
<View style={styles.icon}>{Icon}</View>
<Text style={styles.optionText} numberOfLines={1} adjustsFontSizeToFit={true} minimumFontScale={0.6}>
{text}
</Text>
</ContextMenuOption>
)
},
)
const MenuHeader = React.memo<{
coverArt?: string
artistId?: string
albumId?: string
title: string
subtitle?: string
}>(({ coverArt, artistId, albumId, title, subtitle }) => {
let CoverArtComponent = <></>
if (artistId) {
CoverArtComponent = (
<CoverArt
type="artist"
artistId={artistId}
style={styles.coverArt}
resizeMode="cover"
round={true}
size="thumbnail"
fadeDuration={0}
/>
)
} else if (albumId) {
CoverArtComponent = (
<CoverArt
type="album"
albumId={albumId}
style={styles.coverArt}
resizeMode="cover"
size="thumbnail"
fadeDuration={0}
/>
)
} else {
CoverArtComponent = (
<CoverArt
type="cover"
coverArt={coverArt}
style={styles.coverArt}
resizeMode="cover"
size="thumbnail"
fadeDuration={0}
/>
)
}
return (
<View style={styles.menuHeader}>
{CoverArtComponent}
<View style={styles.menuHeaderText}>
<Text numberOfLines={1} style={styles.menuTitle}>
{title}
</Text>
{subtitle ? (
<Text numberOfLines={1} style={styles.menuSubtitle}>
{subtitle}
</Text>
) : (
<></>
)}
</View>
</View>
)
})
const OptionStar = withSuspenseMemo<{
id: string
type: StarrableItemType
additionalText?: string
}>(({ id, type, additionalText: text }) => {
const { query, toggle } = useStar(id, type)
const { t } = useTranslation()
return (
<ContextMenuIconTextOption
IconComponentRaw={<Star starred={!!query.data} size={26} />}
text={(query.data ? t('context.actions.unstar') : t('context.actions.star')) + (text ? ` ${text}` : '')}
onSelect={() => toggle.mutate()}
/>
)
})
const OptionViewArtist = withSuspenseMemo<{
navigation: NavigationProp<any>
artist?: string
artistId?: string
}>(({ navigation, artist, artistId }) => {
const { t } = useTranslation()
if (!artist || !artistId) {
return <></>
}
return (
<ContextMenuIconTextOption
IconComponent={IconFA}
name="microphone"
size={26}
text={t('resources.artist.actions.view')}
onSelect={() => navigation.navigate('artist', { id: artistId, title: artist })}
/>
)
})
const OptionViewAlbum = withSuspenseMemo<{
navigation: NavigationProp<any>
album?: string
albumId?: string
}>(({ navigation, album, albumId }) => {
const { t } = useTranslation()
if (!album || !albumId) {
return <></>
}
return (
<ContextMenuIconTextOption
IconComponent={IconFA5}
name="compact-disc"
size={26}
text={t('resources.album.actions.view')}
onSelect={() => navigation.navigate('album', { id: albumId, title: album })}
/>
)
})
// const OptionDownload = React.memo<{
// itemType: string
// }>(({ itemType }) => (
// <ContextMenuIconTextOption IconComponent={IconMat} name="file-download" size={26} text={`Download ${itemType}`} />
// ))
export type AlbumContextPressableProps = ContextMenuProps & {
album: Album
}
export const AlbumContextPressable: React.FC<AlbumContextPressableProps> = props => {
const navigation = useNavigation()
const { album, children } = props
return (
<ContextMenu
{...props}
menuHeader={<MenuHeader title={album.name} subtitle={album.artist} coverArt={album.coverArt} />}
menuOptions={
<>
<OptionStar id={album.id} type={album.itemType} />
<OptionViewArtist artist={album.artist} artistId={album.artistId} navigation={navigation} />
{/* <OptionDownload itemType={album.itemType} /> */}
</>
}>
{children}
</ContextMenu>
)
}
export type SongContextPressableProps = ContextMenuProps & {
song: Song
}
export const SongContextPressable: React.FC<SongContextPressableProps> = props => {
const navigation = useNavigation()
const { song, children } = props
return (
<ContextMenu
{...props}
menuHeader={<MenuHeader title={song.title} subtitle={song.artist} albumId={song.albumId} />}
menuOptions={
<>
<OptionStar id={song.id} type={song.itemType} />
<OptionViewArtist artist={song.artist} artistId={song.artistId} navigation={navigation} />
<OptionViewAlbum album={song.album} albumId={song.albumId} navigation={navigation} />
{/* <OptionDownload itemType={song.itemType} /> */}
</>
}>
{children}
</ContextMenu>
)
}
export type ArtistContextPressableProps = ContextMenuProps & {
artist: Artist
}
export const ArtistContextPressable: React.FC<ArtistContextPressableProps> = props => {
const { artist, children } = props
return (
<ContextMenu
{...props}
menuHeader={<MenuHeader title={artist.name} artistId={artist.id} />}
menuOptions={
<>
<OptionStar id={artist.id} type={artist.itemType} />
{/* <OptionDownload itemType={artist.itemType} /> */}
</>
}>
{children}
</ContextMenu>
)
}
export type NowPlayingContextPressableProps = ContextMenuProps & {
song: Song
}
export const NowPlayingContextPressable: React.FC<NowPlayingContextPressableProps> = props => {
const navigation = useNavigation()
const { song, children } = props
return (
<ContextMenu
{...props}
menuHeader={<MenuHeader title={song.title} subtitle={song.artist} albumId={song.albumId} />}
menuOptions={
<>
<OptionStar id={song.id} type={song.itemType} />
<OptionViewArtist artist={song.artist} artistId={song.artistId} navigation={navigation} />
<OptionViewAlbum album={song.album} albumId={song.albumId} navigation={navigation} />
</>
}>
{children}
</ContextMenu>
)
}
const styles = StyleSheet.create({
optionsContainer: {
backgroundColor: 'rgba(45, 45, 45, 0.95)',
maxHeight: 365,
},
optionsWrapper: {
// marginBottom: 10,
paddingHorizontal: 20,
// backgroundColor: 'purple',
},
menuHeader: {
paddingTop: 14,
paddingBottom: 10,
paddingHorizontal: 20,
flex: 1,
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
},
coverArt: {
width: 42,
height: 42,
},
menuHeaderText: {
flex: 1,
marginLeft: 10,
},
menuTitle: {
fontFamily: font.semiBold,
fontSize: 16,
color: colors.text.primary,
},
menuSubtitle: {
fontFamily: font.regular,
fontSize: 14,
color: colors.text.secondary,
},
option: {
paddingVertical: 8,
// paddingHorizontal: 100,
flexDirection: 'row',
alignItems: 'center',
// backgroundColor: 'blue',
overflow: 'hidden',
},
icon: {
marginRight: 10,
width: 32,
height: 32,
justifyContent: 'center',
alignItems: 'center',
// backgroundColor: 'red',
},
optionText: {
fontFamily: font.semiBold,
fontSize: 16,
color: colors.text.primary,
// backgroundColor: 'green',
},
})
-133
View File
@@ -1,133 +0,0 @@
import { useQueryAlbumCoverArtPath, useQueryArtistArtPath, useQueryCoverArtPath } from '@app/hooks/query'
import { CacheImageSize } from '@app/models/cache'
import colors from '@app/styles/colors'
import React, { useState } from 'react'
import {
ActivityIndicator,
Image,
ImageResizeMode,
ImageSourcePropType,
ImageStyle,
StyleSheet,
View,
ViewStyle,
} from 'react-native'
type BaseProps = {
style?: ViewStyle
imageStyle?: ImageStyle
resizeMode?: ImageResizeMode
round?: boolean
size: CacheImageSize
fadeDuration?: number
}
type ArtistCoverArtProps = BaseProps & {
type: 'artist'
artistId: string
}
type CoverArtProps = BaseProps & {
type: 'cover'
coverArt?: string
}
type AlbumIdProps = BaseProps & {
type: 'album'
albumId?: string
}
type ImageSourceProps = BaseProps & {
data?: string
isFetching: boolean
isExistingFetching: boolean
}
const ImageSource = React.memo<ImageSourceProps>(
({ style, imageStyle, resizeMode, data, isFetching, isExistingFetching, fadeDuration }) => {
const [error, setError] = useState(false)
let source: ImageSourcePropType
if (!error && data) {
source = { uri: `file://${data}` }
} else {
source = require('@res/fallback.png')
}
return (
<>
{isExistingFetching ? (
<View style={{ height: style?.height, width: style?.width }} />
) : (
<Image
source={source}
fadeDuration={fadeDuration === undefined ? 250 : fadeDuration}
resizeMode={resizeMode || 'contain'}
style={[{ height: style?.height, width: style?.width }, imageStyle]}
onError={() => setError(true)}
/>
)}
{isFetching && (
<ActivityIndicator animating={true} size="large" color={colors.accent} style={styles.indicator} />
)}
</>
)
},
)
const ArtistImage = React.memo<ArtistCoverArtProps>(props => {
const { data, isFetching, isExistingFetching } = useQueryArtistArtPath(props.artistId, props.size)
return <ImageSource data={data} isFetching={isFetching} isExistingFetching={isExistingFetching} {...props} />
})
const CoverArtImage = React.memo<CoverArtProps>(props => {
const { data, isFetching, isExistingFetching } = useQueryCoverArtPath(props.coverArt, props.size)
return <ImageSource data={data} isFetching={isFetching} isExistingFetching={isExistingFetching} {...props} />
})
const AlbumIdIamge = React.memo<AlbumIdProps>(props => {
const { data, isFetching, isExistingFetching } = useQueryAlbumCoverArtPath(props.albumId, props.size)
return <ImageSource data={data} isFetching={isFetching} isExistingFetching={isExistingFetching} {...props} />
})
const CoverArt = React.memo<CoverArtProps | ArtistCoverArtProps | AlbumIdProps>(props => {
const viewStyles = [props.style]
if (props.round) {
viewStyles.push(styles.round)
}
let imageComponent
switch (props.type) {
case 'artist':
imageComponent = <ArtistImage {...(props as ArtistCoverArtProps)} />
break
case 'album':
imageComponent = <AlbumIdIamge {...(props as AlbumIdProps)} />
break
default:
imageComponent = <CoverArtImage {...(props as CoverArtProps)} />
break
}
return <View style={viewStyles}>{imageComponent}</View>
})
const styles = StyleSheet.create({
round: {
overflow: 'hidden',
borderRadius: 1000,
},
indicator: {
height: '100%',
width: '100%',
position: 'absolute',
},
artistImage: {
backgroundColor: 'rgba(81, 28, 99, 0.4)',
},
})
export default CoverArt
-129
View File
@@ -1,129 +0,0 @@
import colors from '@app/styles/colors'
import font from '@app/styles/font'
import React from 'react'
import { Text, StyleSheet, View } from 'react-native'
import { MenuOption, Menu, MenuTrigger, MenuOptions, renderers } from 'react-native-popup-menu'
import PressableOpacity from './PressableOpacity'
import Icon from 'react-native-vector-icons/MaterialCommunityIcons'
import { ScrollView } from 'react-native-gesture-handler'
const { SlideInMenu } = renderers
export type OptionData = {
value: string
text: string
}
const Option = React.memo<{
text: string
value: string
selected?: boolean
}>(({ text, value, selected }) => (
<MenuOption style={styles.option} value={value}>
{selected ? (
<Icon name="checkbox-marked-circle" size={32} color={colors.accent} style={styles.icon} />
) : (
<Icon name="checkbox-blank-circle-outline" size={32} color={colors.text.secondary} style={styles.icon} />
)}
<Text style={styles.optionText} numberOfLines={1} adjustsFontSizeToFit={true} minimumFontScale={0.6}>
{text}
</Text>
</MenuOption>
))
const FilterButton = React.memo<{
value?: string
data: OptionData[]
onSelect?: (selection: string) => void
title: string
}>(({ value, data, onSelect, title }) => {
return (
<Menu onSelect={onSelect} renderer={SlideInMenu}>
<MenuTrigger
customStyles={{
triggerOuterWrapper: styles.filterOuterWrapper,
triggerWrapper: styles.filterWrapper,
triggerTouchable: { style: styles.filter },
TriggerTouchableComponent: PressableOpacity,
}}>
<Icon name="filter-variant" color="white" size={30} />
</MenuTrigger>
<MenuOptions
customStyles={{
optionsWrapper: styles.optionsWrapper,
optionsContainer: styles.optionsContainer,
}}>
<ScrollView style={styles.optionsScroll} overScrollMode="never">
<View style={styles.header}>
<Text style={styles.headerText} numberOfLines={2} ellipsizeMode="clip">
{title}
</Text>
</View>
{data.map(o => (
<Option key={o.value} text={o.text} value={o.value} selected={o.value === value} />
))}
</ScrollView>
</MenuOptions>
</Menu>
)
})
const styles = StyleSheet.create({
filterOuterWrapper: {
position: 'absolute',
bottom: 20,
right: 20,
},
filterWrapper: {},
filter: {
borderRadius: 32,
width: 50,
height: 50,
elevation: 5,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: colors.accent,
},
optionsScroll: {
maxHeight: 260,
},
optionsWrapper: {
overflow: 'hidden',
},
optionsContainer: {
backgroundColor: 'rgba(45, 45, 45, 0.95)',
},
header: {
paddingHorizontal: 20,
// paddingVertical: 10,
marginTop: 16,
marginBottom: 6,
},
headerText: {
fontFamily: font.bold,
fontSize: 20,
color: colors.text.primary,
},
option: {
paddingVertical: 8,
paddingHorizontal: 20,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'flex-start',
},
optionText: {
fontFamily: font.semiBold,
fontSize: 16,
color: colors.text.primary,
},
icon: {
marginRight: 14,
width: 32,
height: 32,
justifyContent: 'center',
alignItems: 'center',
// backgroundColor: 'red',
},
})
export default FilterButton
-46
View File
@@ -1,46 +0,0 @@
import React from 'react'
import { useWindowDimensions, ViewStyle } from 'react-native'
import LinearGradient from 'react-native-linear-gradient'
import colorStyles from '@app/styles/colors'
export type GradientBackgroundPropsBase = {
height?: number | string
width?: number | string
position?: 'relative' | 'absolute'
style?: ViewStyle
}
export type GradientBackgroundProps = GradientBackgroundPropsBase & {
colors?: string[]
locations?: number[]
}
const GradientBackground: React.FC<GradientBackgroundProps> = ({
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
@@ -1,54 +0,0 @@
import React from 'react'
import { FlatList, FlatListProps, useWindowDimensions, View, StyleSheet } from 'react-native'
import colors from '@app/styles/colors'
import GradientBackground, { GradientBackgroundProps } from '@app/components/GradientBackground'
import { useLayout } from '@react-native-community/hooks'
import NothingHere from './NothingHere'
import GradientImageBackground, { GradientImageBackgroundProps } from './GradientImageBackground'
export type GradientBackgroundHeaderFlatListPropsBase<ItemT> = FlatListProps<ItemT> & {
contentMarginTop?: number
}
export type GradientBackgroundHeaderFlatListProp<ItemT> = GradientBackgroundHeaderFlatListPropsBase<ItemT> & {
BackgroundComponent: typeof GradientImageBackground | typeof GradientBackground
backgroundProps?: GradientImageBackgroundProps | GradientBackgroundProps
}
function GradientBackgroundHeaderFlatList<ItemT>(props: GradientBackgroundHeaderFlatListProp<ItemT>) {
const window = useWindowDimensions()
const headerLayout = useLayout()
let marginBottom = -window.height + (props.contentMarginTop || 0)
if (props.ListHeaderComponent) {
marginBottom += headerLayout.height || window.height
}
const headerStyle = { marginBottom }
return (
<FlatList
{...props}
contentContainerStyle={[props.contentContainerStyle, { minHeight: window.height }]}
style={[props.style, styles.list]}
ListHeaderComponent={
<props.BackgroundComponent position="relative" {...props.backgroundProps}>
<View onLayout={headerLayout.onLayout}>{props.ListHeaderComponent}</View>
</props.BackgroundComponent>
}
ListHeaderComponentStyle={[headerStyle]}
ListEmptyComponent={props.ListEmptyComponent || <NothingHere style={styles.nothing} />}
/>
)
}
const styles = StyleSheet.create({
list: {
backgroundColor: colors.gradient.low,
},
nothing: {
width: '100%',
},
})
export default GradientBackgroundHeaderFlatList
-15
View File
@@ -1,15 +0,0 @@
import GradientBackground, { GradientBackgroundProps } from '@app/components/GradientBackground'
import React from 'react'
import GradientBackgroundHeaderFlatList, {
GradientBackgroundHeaderFlatListPropsBase,
} from './GradientBackgroundHeaderFlatList'
export type GradientFlatListProps<ItemT> = GradientBackgroundHeaderFlatListPropsBase<ItemT> & {
backgroundProps?: GradientBackgroundProps
}
function GradientFlatList<ItemT>(props: GradientFlatListProps<ItemT>) {
return <GradientBackgroundHeaderFlatList BackgroundComponent={GradientBackground} {...props} />
}
export default GradientFlatList
@@ -1,86 +0,0 @@
import { useNavigation } from '@react-navigation/native'
import React, { useEffect, useState } from 'react'
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, { GradientBackgroundPropsBase } from '@app/components/GradientBackground'
export type GradientImageBackgroundProps = GradientBackgroundPropsBase & {
imagePath?: string
onGetColor?: (color: string) => void
}
const GradientImageBackground: React.FC<GradientImageBackgroundProps> = ({
height,
width,
position,
style,
imagePath,
children,
onGetColor,
}) => {
const [highColor, setHighColor] = useState<string>(colors.gradient.high)
const navigation = useNavigation()
useEffect(() => {
async function getColors() {
if (imagePath === undefined) {
return
}
const cachedResult = ImageColors.cache.getItem(imagePath)
let res: AndroidImageColors
if (cachedResult) {
res = cachedResult as AndroidImageColors
} else {
const path = `file://${imagePath}`
res = (await ImageColors.getColors(path, {
cache: true,
key: imagePath,
})) as AndroidImageColors
}
if (res.muted && res.muted !== '#000000') {
setHighColor(res.muted)
} else if (res.darkMuted && res.darkMuted !== '#000000') {
setHighColor(res.darkMuted)
} else if (res.vibrant && res.vibrant !== '#000000') {
setHighColor(res.vibrant)
} else if (res.darkVibrant && res.darkVibrant !== '#000000') {
setHighColor(res.darkVibrant)
} else if (res.lightVibrant && res.lightVibrant !== '#000000') {
setHighColor(res.lightVibrant)
} else if (res.lightMuted && res.lightMuted !== '#000000') {
setHighColor(res.lightMuted)
}
}
getColors()
}, [imagePath])
useEffect(() => {
navigation.setOptions({
headerStyle: {
backgroundColor: highColor,
},
})
}, [navigation, highColor])
useEffect(() => {
onGetColor && onGetColor(highColor)
}, [onGetColor, 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 GradientImageBackground
-15
View File
@@ -1,15 +0,0 @@
import React from 'react'
import GradientBackgroundHeaderFlatList, {
GradientBackgroundHeaderFlatListPropsBase,
} from './GradientBackgroundHeaderFlatList'
import GradientImageBackground, { GradientImageBackgroundProps } from './GradientImageBackground'
export type GradientImageFlatListProps<ItemT> = GradientBackgroundHeaderFlatListPropsBase<ItemT> & {
backgroundProps?: GradientImageBackgroundProps
}
function GradientImageFlatList<ItemT>(props: GradientImageFlatListProps<ItemT>) {
return <GradientBackgroundHeaderFlatList BackgroundComponent={GradientImageBackground} {...props} />
}
export default GradientImageFlatList
@@ -1,31 +0,0 @@
import GradientImageBackground, { GradientImageBackgroundProps } from '@app/components/GradientImageBackground'
import colors from '@app/styles/colors'
import dimensions from '@app/styles/dimensions'
import React from 'react'
import { ScrollView, ScrollViewProps, useWindowDimensions } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
const GradientImageScrollView: React.FC<ScrollViewProps & GradientImageBackgroundProps> = props => {
const layout = useWindowDimensions()
const paddingTop = useSafeAreaInsets().top
const minHeight = layout.height - (dimensions.header + paddingTop + dimensions.bottom())
return (
<ScrollView
overScrollMode="never"
{...props}
style={[
props.style,
{
backgroundColor: colors.gradient.low,
},
]}
contentContainerStyle={[{ minHeight }, props.contentContainerStyle]}>
<GradientImageBackground height={minHeight} imagePath={props.imagePath} onGetColor={props.onGetColor} />
{props.children}
</ScrollView>
)
}
export default GradientImageScrollView
-34
View File
@@ -1,34 +0,0 @@
import GradientBackground from '@app/components/GradientBackground'
import colors from '@app/styles/colors'
import dimensions from '@app/styles/dimensions'
import React, { ForwardedRef, PropsWithChildren } from 'react'
import { ScrollView, ScrollViewProps, useWindowDimensions } from 'react-native'
import Animated from 'react-native-reanimated'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
export type GradientScrollViewProps = PropsWithChildren<
Animated.AnimateProps<ScrollViewProps> & {
offset?: number
}
>
const GradientScrollView = React.forwardRef<ScrollView, GradientScrollViewProps>((props, ref) => {
const layout = useWindowDimensions()
const paddingTop = useSafeAreaInsets().top
const minHeight = layout.height - (dimensions.header + paddingTop + dimensions.bottom())
return (
<Animated.ScrollView
ref={ref as ForwardedRef<Animated.ScrollView>}
overScrollMode="never"
{...props}
style={[props.style, { backgroundColor: colors.gradient.low }]}
contentContainerStyle={[props.contentContainerStyle as any, { minHeight }]}>
<GradientBackground style={{ top: props.offset }} />
{props.children}
</Animated.ScrollView>
)
})
export default GradientScrollView
-22
View File
@@ -1,22 +0,0 @@
import colors from '@app/styles/colors'
import font from '@app/styles/font'
import React from 'react'
import { StyleSheet, Text, TextStyle } from 'react-native'
const Header: React.FC<{
style?: TextStyle
}> = ({ children, style }) => {
return <Text style={[styles.text, style]}>{children}</Text>
}
const styles = StyleSheet.create({
text: {
fontFamily: font.bold,
fontSize: 24,
color: colors.text.primary,
marginTop: 18,
marginBottom: 12,
},
})
export default Header
-112
View File
@@ -1,112 +0,0 @@
import { Album, Song } from '@app/models/library'
import colors from '@app/styles/colors'
import dimensions from '@app/styles/dimensions'
import font from '@app/styles/font'
import { useNavigation } from '@react-navigation/core'
import { ReactComponentLike } from 'prop-types'
import React, { useCallback } from 'react'
import { StyleSheet, Text, View, ViewStyle } from 'react-native'
import Animated from 'react-native-reanimated'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import IconMat from 'react-native-vector-icons/MaterialIcons'
import { AlbumContextPressable, NowPlayingContextPressable } from './ContextMenu'
import PressableOpacity from './PressableOpacity'
export type HeaderContextItem = Song | Album
const More = React.memo<{ contextItem?: HeaderContextItem }>(({ contextItem }) => {
const moreIcon = <IconMat name="more-vert" color="white" size={25} />
let context: JSX.Element
switch (contextItem?.itemType) {
case 'song':
context = (
<NowPlayingContextPressable
menuStyle={styles.icons}
triggerWrapperStyle={styles.icons}
song={contextItem}
triggerOnLongPress={false}>
{moreIcon}
</NowPlayingContextPressable>
)
break
case 'album':
context = (
<AlbumContextPressable
menuStyle={styles.icons}
triggerWrapperStyle={styles.icons}
album={contextItem}
triggerOnLongPress={false}>
{moreIcon}
</AlbumContextPressable>
)
break
default:
context = <></>
}
return context
})
const HeaderBar = React.memo<{
title?: string
headerStyle?: Animated.AnimateStyle<ViewStyle> | Animated.AnimateStyle<ViewStyle>[]
HeaderCenter?: ReactComponentLike
contextItem?: HeaderContextItem
}>(({ title, headerStyle, HeaderCenter, contextItem }) => {
const navigation = useNavigation()
const top = useSafeAreaInsets().top
const back = useCallback(() => {
navigation.goBack()
}, [navigation])
const _headerStyle = Array.isArray(headerStyle) ? headerStyle : [headerStyle]
return (
<Animated.View style={[styles.container, { paddingTop: top, height: dimensions.header + top }, ..._headerStyle]}>
<PressableOpacity onPress={back} style={styles.icons}>
<IconMat name="arrow-back" color="white" size={25} />
</PressableOpacity>
<View style={styles.center}>
{HeaderCenter ? (
<HeaderCenter />
) : (
<Text numberOfLines={1} style={styles.title}>
{title}
</Text>
)}
</View>
<More contextItem={contextItem} />
</Animated.View>
)
})
const styles = StyleSheet.create({
container: {
backgroundColor: colors.gradient.high,
width: '100%',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
icons: {
height: 42,
width: 42,
marginHorizontal: 8,
alignItems: 'center',
justifyContent: 'center',
},
center: {
flex: 1,
},
title: {
fontFamily: font.semiBold,
fontSize: 18,
color: colors.text.primary,
flex: 1,
textAlignVertical: 'center',
paddingLeft: 14,
},
})
export default HeaderBar
-274
View File
@@ -1,274 +0,0 @@
import { useIsPlaying } from '@app/hooks/trackplayer'
import { Album, Artist, ListableItem, Song } from '@app/models/library'
import colors from '@app/styles/colors'
import font from '@app/styles/font'
import { useNavigation } from '@react-navigation/native'
import React, { useCallback } from 'react'
import { StyleProp, StyleSheet, Text, View, ViewStyle } from 'react-native'
import IconFA5 from 'react-native-vector-icons/FontAwesome5'
import IconMat from 'react-native-vector-icons/MaterialIcons'
import { AlbumContextPressable, ArtistContextPressable, SongContextPressable } from './ContextMenu'
import CoverArt from './CoverArt'
import PressableOpacity from './PressableOpacity'
import { PressableStar } from './Star'
import equal from 'fast-deep-equal/es6/react'
const TitleTextSong = React.memo<{
contextId?: string
queueId: number
title?: string
}>(({ contextId, queueId, title }) => {
const playing = useIsPlaying(contextId, queueId)
return (
<View style={styles.textLine}>
<Text style={[styles.title, { color: playing ? colors.accent : colors.text.primary }]}>
{playing && (
<View style={styles.playingIcon}>
<IconFA5 name="play" size={9} color={colors.accent} />
</View>
)}
{title}
</Text>
</View>
)
})
const TitleText = React.memo<{
title?: string
}>(({ title }) => {
return (
<View style={styles.textLine}>
<Text style={styles.title}>{title}</Text>
</View>
)
})
const ListItem: React.FC<{
item: ListableItem
contextId?: string
queueId?: number
onPress?: () => void
showArt?: boolean
showStar?: boolean
listStyle?: 'big' | 'small'
subtitle?: string
style?: StyleProp<ViewStyle>
disabled?: boolean
}> = ({ item, contextId, queueId, onPress, showArt, showStar, subtitle, listStyle, style, disabled }) => {
const navigation = useNavigation()
showStar = showStar === undefined ? true : showStar
listStyle = listStyle || 'small'
const sizeStyle = listStyle === 'big' ? bigStyles : smallStyles
if (!onPress) {
switch (item.itemType) {
case 'album':
onPress = () => navigation.navigate('album', { id: item.id, title: item.name, album: item })
break
case 'artist':
onPress = () => navigation.navigate('artist', { id: item.id, title: item.name })
break
case 'playlist':
onPress = () => navigation.navigate('playlist', { id: item.id, title: item.name, playlist: item })
break
}
}
if (!subtitle) {
switch (item.itemType) {
case 'song':
case 'album':
subtitle = item.artist
break
case 'playlist':
subtitle = item.comment
break
}
}
const itemPressable = useCallback(
({ children }) => (
<PressableOpacity onPress={onPress} style={styles.item} disabled={disabled}>
{children}
</PressableOpacity>
),
[disabled, onPress],
)
const albumPressable = useCallback(
({ children }) => (
<AlbumContextPressable
album={item as Album}
onPress={onPress}
triggerWrapperStyle={styles.item}
disabled={disabled}>
{children}
</AlbumContextPressable>
),
[disabled, item, onPress],
)
const songPressable = useCallback(
({ children }) => (
<SongContextPressable song={item as Song} onPress={onPress} triggerWrapperStyle={styles.item} disabled={disabled}>
{children}
</SongContextPressable>
),
[disabled, item, onPress],
)
const artistPressable = useCallback(
({ children }) => (
<ArtistContextPressable
artist={item as Artist}
onPress={onPress}
triggerWrapperStyle={styles.item}
disabled={disabled}>
{children}
</ArtistContextPressable>
),
[disabled, item, onPress],
)
let PressableComponent = itemPressable
if (item.itemType === 'album') {
PressableComponent = albumPressable
} else if (item.itemType === 'song') {
PressableComponent = songPressable
} else if (item.itemType === 'artist') {
PressableComponent = artistPressable
}
let title = <></>
if (item.itemType === 'song' && queueId !== undefined) {
title = <TitleTextSong contextId={contextId} queueId={queueId} title={item.title} />
} else if (item.itemType !== 'song') {
title = <TitleText title={item.name} />
}
const artStyle = { ...styles.art, ...sizeStyle.art }
const resizeMode = 'cover'
let coverArt = <></>
if (item.itemType === 'artist') {
coverArt = (
<CoverArt
type="artist"
artistId={item.id}
round={true}
style={artStyle}
resizeMode={resizeMode}
size="thumbnail"
/>
)
} else if (item.itemType === 'song') {
coverArt = (
<CoverArt type="album" albumId={item.albumId} style={artStyle} resizeMode={resizeMode} size="thumbnail" />
)
} else {
coverArt = (
<CoverArt type="cover" coverArt={item.coverArt} style={artStyle} resizeMode={resizeMode} size="thumbnail" />
)
}
return (
<View style={[styles.container, sizeStyle.container, style]}>
<PressableComponent>
{showArt && coverArt}
<View style={styles.text}>
{title}
{subtitle !== undefined && (
<View style={styles.textLine}>
{false && (
<IconMat
name="file-download-done"
size={17}
color={colors.text.secondary}
style={styles.downloadedIcon}
/>
)}
<Text style={styles.subtitle}>{subtitle}</Text>
</View>
)}
</View>
</PressableComponent>
<View style={styles.controls}>
{showStar && item.itemType !== 'playlist' && (
<PressableStar id={item.id} type={item.itemType} size={26} style={styles.controlItem} disabled={disabled} />
)}
</View>
</View>
)
}
const styles = StyleSheet.create({
container: {
marginBottom: 14,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
item: {
flex: 1,
flexDirection: 'row',
justifyContent: 'flex-start',
alignItems: 'center',
},
art: {
marginRight: 10,
},
text: {
flex: 1,
},
textLine: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'flex-start',
},
title: {
fontSize: 16,
fontFamily: font.semiBold,
color: colors.text.primary,
},
playingIcon: {
paddingRight: 4,
paddingBottom: 1,
},
downloadedIcon: {
marginRight: 2,
marginLeft: -3,
},
subtitle: {
fontSize: 14,
fontFamily: font.regular,
color: colors.text.secondary,
},
controls: {
flexDirection: 'row',
alignItems: 'center',
},
controlItem: {
marginLeft: 16,
},
})
const smallStyles = StyleSheet.create({
container: {
minHeight: 50,
},
art: {
height: 50,
width: 50,
},
})
const bigStyles = StyleSheet.create({
container: {
minHeight: 70,
},
art: {
height: 70,
width: 70,
},
})
export default React.memo(ListItem, equal)
-65
View File
@@ -1,65 +0,0 @@
import Button from '@app/components/Button'
import { Song } from '@app/models/library'
import colors from '@app/styles/colors'
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { StyleProp, StyleSheet, View, ViewStyle } from 'react-native'
import Icon from 'react-native-vector-icons/Ionicons'
import IconMat from 'react-native-vector-icons/MaterialIcons'
import { withSuspenseMemo } from './withSuspense'
const ListPlayerControls = withSuspenseMemo<{
songs: Song[]
listType: 'album' | 'playlist'
style?: StyleProp<ViewStyle>
play: () => void
shuffle: () => void
disabled?: boolean
}>(({ listType, style, play, shuffle, disabled }) => {
const [downloaded, setDownloaded] = useState(false)
const { t } = useTranslation()
return (
<View style={[styles.controls, style]}>
<View style={styles.controlsSide}>
<Button
disabled={true}
buttonStyle={downloaded ? 'highlight' : 'hollow'}
onPress={() => setDownloaded(!downloaded)}>
{downloaded ? (
<IconMat name="file-download-done" size={26} color={colors.text.primary} />
) : (
<IconMat name="file-download" size={26} color={colors.text.primary} />
)}
</Button>
</View>
<View style={styles.controlsCenter}>
<Button title={t(`resources.${listType}.actions.play`)} disabled={disabled} onPress={play} />
</View>
<View style={styles.controlsSide}>
<Button disabled={disabled} onPress={shuffle}>
<Icon name="shuffle" size={26} color="white" />
</Button>
</View>
</View>
)
})
const styles = StyleSheet.create({
controls: {
flexDirection: 'row',
},
controlsSide: {
flex: 4,
flexDirection: 'row',
justifyContent: 'center',
},
controlsCenter: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
maxWidth: '65%',
},
})
export default ListPlayerControls
-39
View File
@@ -1,39 +0,0 @@
import font from '@app/styles/font'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { Text, View, StyleSheet, ViewStyle } from 'react-native'
import Icon from 'react-native-vector-icons/MaterialCommunityIcons'
import { withSuspenseMemo } from './withSuspense'
const NothingHere = withSuspenseMemo<{
height?: number
width?: number
style?: ViewStyle
}>(({ height, width, style }) => {
const { t } = useTranslation()
height = height || 200
width = width || 200
return (
<View style={[styles.container, { height, width }, style]}>
<Icon name="music-rest-quarter" color={styles.text.color} size={width / 2} />
<Text style={[styles.text, { fontSize: width / 8 }]} numberOfLines={3}>
{t('messages.nothingHere')}
</Text>
</View>
)
})
const styles = StyleSheet.create({
container: {
justifyContent: 'center',
alignItems: 'center',
opacity: 0.25,
},
text: {
fontFamily: font.lightItalic,
color: 'white',
},
})
export default NothingHere
-150
View File
@@ -1,150 +0,0 @@
import CoverArt from '@app/components/CoverArt'
import PressableOpacity from '@app/components/PressableOpacity'
import { usePause, usePlay } from '@app/hooks/trackplayer'
import { useStore } from '@app/state/store'
import colors from '@app/styles/colors'
import font from '@app/styles/font'
import { useNavigation } from '@react-navigation/native'
import React, { useCallback } from 'react'
import { ActivityIndicator, Pressable, StyleSheet, Text, View, ViewStyle } from 'react-native'
import { State } from 'react-native-track-player'
import IconFA5 from 'react-native-vector-icons/FontAwesome5'
const ProgressBar = React.memo(() => {
const position = useStore(store => store.progress.position)
const duration = useStore(store => store.progress.duration)
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 Controls = React.memo(() => {
const state = useStore(store => store.playerState)
const play = usePlay()
const pause = usePause()
let playPauseIcon: string
switch (state) {
case State.Playing:
playPauseIcon = 'pause'
break
default:
playPauseIcon = 'play'
break
}
const action = useCallback(() => {
if (state === State.Playing) {
pause()
} else {
play()
}
}, [state, play, pause])
return (
<View style={styles.controls}>
{state === State.Buffering ? (
<ActivityIndicator color="white" size="large" animating={true} />
) : (
<PressableOpacity onPress={action} hitSlop={14}>
<IconFA5 name={playPauseIcon} size={28} color="white" />
</PressableOpacity>
)}
</View>
)
})
const NowPlayingBar = React.memo(() => {
const navigation = useNavigation()
const currentTrackExists = useStore(store => !!store.currentTrack)
const albumId = useStore(store => store.currentTrack?.albumId)
const title = useStore(store => store.currentTrack?.title)
const artist = useStore(store => store.currentTrack?.artist)
const displayStyle: ViewStyle = { display: currentTrackExists ? 'flex' : 'none' }
return (
<Pressable onPress={() => navigation.navigate('now-playing')} style={[styles.container, displayStyle]}>
<ProgressBar />
<View style={styles.subContainer}>
<CoverArt
type="album"
style={{ height: styles.subContainer.height, width: styles.subContainer.height }}
albumId={albumId}
size="thumbnail"
fadeDuration={0}
/>
<View style={styles.detailsContainer}>
<Text numberOfLines={1} style={styles.detailsTitle}>
{title}
</Text>
<Text numberOfLines={1} style={styles.detailsAlbum}>
{artist}
</Text>
</View>
<Controls />
</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
-80
View File
@@ -1,80 +0,0 @@
import React, { useCallback, useState } from 'react'
import { GestureResponderEvent, LayoutRectangle, Pressable, PressableProps, StyleSheet } from 'react-native'
type PressableOpacityProps = PressableProps & {
ripple?: boolean
rippleColor?: string
unstable_pressDelay?: number
}
const PressableOpacity: React.FC<PressableOpacityProps> = props => {
const [opacity, setOpacity] = useState(1)
const [dimensions, setDimensions] = useState<LayoutRectangle | undefined>(undefined)
props = {
...props,
unstable_pressDelay: props.unstable_pressDelay === undefined ? 60 : props.unstable_pressDelay,
}
const onPressIn = useCallback<(event: GestureResponderEvent) => void>(
data => {
if (props.disabled) {
return
}
setOpacity(0.4)
props.onPressIn ? props.onPressIn(data) : null
},
[props],
)
const onPressOut = useCallback<(event: GestureResponderEvent) => void>(
data => {
if (props.disabled) {
return
}
setOpacity(1)
props.onPressOut ? props.onPressOut(data) : null
},
[props],
)
const onLongPress = useCallback<(event: GestureResponderEvent) => void>(
data => {
if (props.disabled) {
return
}
setOpacity(1)
props.onLongPress ? props.onLongPress(data) : null
},
[props],
)
return (
<Pressable
{...props}
// eslint-disable-next-line react-native/no-inline-styles
style={[styles.pressable, props.style as any, { opacity }, props.disabled ? { opacity: 0.3 } : {}]}
android_ripple={
props.ripple
? {
color: props.rippleColor || 'rgba(255,255,255,0.26)',
radius: dimensions ? dimensions.width / 2 : undefined,
borderless: true,
}
: undefined
}
onLayout={event => setDimensions(event.nativeEvent.layout)}
onPressIn={onPressIn}
onPressOut={onPressOut}
onLongPress={onLongPress}>
{props.children}
</Pressable>
)
}
const styles = StyleSheet.create({
pressable: {
justifyContent: 'center',
alignItems: 'center',
},
})
export default React.memo(PressableOpacity)
-24
View File
@@ -1,24 +0,0 @@
import { useStore } from '@app/state/store'
import React, { useEffect } from 'react'
import { State, useProgress } from 'react-native-track-player'
const ProgressHook = () => {
const playerState = useStore(store => store.playerState)
const setProgress = useStore(store => store.setProgress)
const progress = useProgress(250)
useEffect(() => {
if (playerState !== State.Playing) {
return
}
if (progress.buffered === 0 && progress.duration === 0 && progress.position === 0) {
return
}
setProgress(progress)
}, [setProgress, progress, playerState])
return <></>
}
export default ProgressHook
-49
View File
@@ -1,49 +0,0 @@
import colors from '@app/styles/colors'
import font from '@app/styles/font'
import React from 'react'
import { StyleSheet, View, Text } from 'react-native'
import PressableOpacity from './PressableOpacity'
export type SettingsItemProps = {
title: string
subtitle?: string
onPress?: () => void
}
const SettingsItem: React.FC<SettingsItemProps> = ({ title, subtitle, onPress, children }) => {
return (
<View style={styles.item}>
<PressableOpacity style={styles.itemText} onPress={onPress}>
<Text style={styles.itemTitle}>{title}</Text>
{subtitle ? <Text style={styles.itemSubtitle}>{subtitle}</Text> : <></>}
</PressableOpacity>
{children}
</View>
)
}
const styles = StyleSheet.create({
item: {
marginBottom: 10,
alignItems: 'stretch',
flexDirection: 'row',
},
itemText: {
flex: 1,
paddingVertical: 10,
alignSelf: 'stretch',
alignItems: 'flex-start',
},
itemTitle: {
fontFamily: font.regular,
color: colors.text.primary,
fontSize: 15,
},
itemSubtitle: {
fontFamily: font.regular,
color: colors.text.secondary,
fontSize: 14,
},
})
export default SettingsItem
-36
View File
@@ -1,36 +0,0 @@
import colors from '@app/styles/colors'
import React from 'react'
import { Switch, StyleSheet } from 'react-native'
import SettingsItem, { SettingsItemProps } from './SettingsItem'
export type SettingsSwitchProps = SettingsItemProps & {
value: boolean
setValue: (value: boolean) => void
}
const SettingsSwitch = React.memo<SettingsSwitchProps>(props => {
const { value, setValue } = props
return (
<SettingsItem onPress={() => setValue(!value)} {...props}>
<Switch
style={styles.switch}
trackColor={{
false: colors.accentLow,
true: colors.accent,
}}
thumbColor={colors.text.primary}
value={value}
onValueChange={setValue}
/>
</SettingsItem>
)
})
const styles = StyleSheet.create({
switch: {
marginLeft: 20,
},
})
export default SettingsSwitch
-31
View File
@@ -1,31 +0,0 @@
import { useStar } from '@app/hooks/query'
import colors from '@app/styles/colors'
import React from 'react'
import { PressableStateCallbackType, StyleProp, ViewStyle } from 'react-native'
import IconFA from 'react-native-vector-icons/FontAwesome'
import PressableOpacity from './PressableOpacity'
export const Star = React.memo<{
starred: boolean
size: number
}>(({ starred, size }) => {
return (
<IconFA name={starred ? 'star' : 'star-o'} color={starred ? colors.accent : colors.text.secondary} size={size} />
)
})
export const PressableStar = React.memo<{
id: string
type: 'album' | 'artist' | 'song'
size: number
style?: StyleProp<ViewStyle> | ((state: PressableStateCallbackType) => StyleProp<ViewStyle>) | undefined
disabled?: boolean
}>(({ id, type, size, style, disabled }) => {
const { query, toggle } = useStar(id, type)
return (
<PressableOpacity onPress={() => toggle.mutate()} style={style} disabled={disabled}>
<Star size={size} starred={!!query.data} />
</PressableOpacity>
)
})
-42
View File
@@ -1,42 +0,0 @@
import colors from '@app/styles/colors'
import font from '@app/styles/font'
import React from 'react'
import { TextInput as ReactTextInput, StyleSheet, KeyboardTypeOptions, StyleProp, TextStyle } from 'react-native'
const TextInput = React.forwardRef<
ReactTextInput,
{
style?: StyleProp<TextStyle>
value?: string
placeholder?: string
onChangeText?: (text: string) => void
onSubmitEditing?: () => void
keyboardType?: KeyboardTypeOptions
}
>(({ style, value, placeholder, onChangeText, onSubmitEditing, keyboardType }, ref) => {
return (
<ReactTextInput
ref={ref}
style={[style, styles.textInput]}
placeholder={placeholder}
placeholderTextColor="grey"
selectionColor={colors.text.secondary}
value={value}
onChangeText={onChangeText}
onSubmitEditing={onSubmitEditing}
keyboardType={keyboardType}
/>
)
})
const styles = StyleSheet.create({
textInput: {
width: '100%',
backgroundColor: '#515151',
fontFamily: font.regular,
fontSize: 18,
color: colors.text.primary,
},
})
export default TextInput
-32
View File
@@ -1,32 +0,0 @@
import React, { ComponentType, FunctionComponent, Suspense, SuspenseProps } from 'react'
export function withSuspense<P extends string | number | object>(
WrappedComponent: ComponentType<P>,
fallback: SuspenseProps['fallback'] = null,
): FunctionComponent<P> {
function ComponentWithSuspense(props: P) {
return (
<Suspense fallback={fallback}>
<WrappedComponent {...props} />
</Suspense>
)
}
return ComponentWithSuspense
}
export function withSuspenseMemo<P extends string | number | object>(
WrappedComponent: ComponentType<P>,
fallback: SuspenseProps['fallback'] = null,
propsAreEqual?: Parameters<typeof React.memo>['1'],
) {
function ComponentWithSuspense(props: P) {
return (
<Suspense fallback={fallback}>
<WrappedComponent {...props} />
</Suspense>
)
}
return React.memo(ComponentWithSuspense, propsAreEqual)
}
-322
View File
@@ -1,322 +0,0 @@
import { CacheImageSize, CacheItemTypeKey } from '@app/models/cache'
import { Album, Artist, Playlist, Song, StarrableItemType } from '@app/models/library'
import { CollectionById } from '@app/models/state'
import queryClient from '@app/query/queryClient'
import { useStore } from '@app/state/store'
import { GetAlbumList2TypeBase, Search3Params, StarParams } from '@app/subsonic/params'
import _ from 'lodash'
import { useInfiniteQuery, useMutation, useQueries, useQuery } from 'react-query'
import {
useFetchAlbum,
useFetchAlbumList,
useFetchArtist,
useFetchArtistInfo,
useFetchArtists,
useFetchArtistTopSongs,
useFetchPlaylist,
useFetchPlaylists,
useFetchSearchResults,
useFetchSong,
useFetchStar,
useFetchUnstar,
} from '../query/fetch/api'
import qk from '@app/query/queryKeys'
import { useFetchExistingFile, useFetchFile } from '@app/query/fetch/file'
export const useQueryArtists = () => useQuery(qk.artists, useFetchArtists())
export const useQueryArtist = (id: string) => {
const fetchArtist = useFetchArtist()
return useQuery(qk.artist(id), () => fetchArtist(id), {
placeholderData: () => {
const artist = queryClient.getQueryData<CollectionById<Artist>>(qk.artists)?.byId[id]
if (artist) {
return { artist, albums: [] }
}
},
})
}
export const useQueryArtistInfo = (id: string) => {
const fetchArtistInfo = useFetchArtistInfo()
return useQuery(qk.artistInfo(id), () => fetchArtistInfo(id))
}
export const useQueryArtistTopSongs = (artistName?: string) => {
const fetchArtistTopSongs = useFetchArtistTopSongs()
const query = useQuery(qk.artistTopSongs(artistName || ''), () => fetchArtistTopSongs(artistName as string), {
enabled: !!artistName,
retry: false,
staleTime: Infinity,
cacheTime: Infinity,
notifyOnChangeProps: ['data', 'isError', 'isFetched', 'isSuccess', 'isFetching'],
})
const querySuccess = query.isFetched && query.isSuccess && query.data && query.data.length > 0
const fetchSearchResults = useFetchSearchResults()
const [artistCount, albumCount, songCount] = [0, 0, 300]
const backupQuery = useQuery(
qk.search(artistName || '', artistCount, albumCount, songCount),
() => fetchSearchResults({ query: artistName as string, artistCount, albumCount, songCount }),
{
select: data => {
const artistNameLower = artistName?.toLowerCase()
const songs = data.songs.filter(s => s.artist?.toLowerCase() === artistNameLower)
// sortBy is a stable sort, so that this doesn't change order arbitrarily and re-render
return _.sortBy(songs, [
s => -(s.playCount || 0),
s => -(s.averageRating || 0),
s => -(s.userRating || 0),
]).slice(0, 50)
},
enabled: !!artistName && !query.isFetching && !querySuccess,
staleTime: Infinity,
cacheTime: Infinity,
notifyOnChangeProps: ['data', 'isError'],
},
)
return querySuccess ? query : backupQuery
}
export const useQueryPlaylists = () => useQuery(qk.playlists, useFetchPlaylists())
export const useQueryPlaylist = (id: string, placeholderPlaylist?: Playlist) => {
const fetchPlaylist = useFetchPlaylist()
const query = useQuery(qk.playlist(id), () => fetchPlaylist(id), {
placeholderData: () => {
if (placeholderPlaylist) {
return { playlist: placeholderPlaylist }
}
const playlist = queryClient.getQueryData<CollectionById<Playlist>>(qk.playlists)?.byId[id]
if (playlist) {
return { playlist, songs: [] }
}
},
})
return query
}
export const useQueryAlbum = (id: string, placeholderAlbum?: Album) => {
const fetchAlbum = useFetchAlbum()
const query = useQuery(qk.album(id), () => fetchAlbum(id), {
placeholderData: (): { album: Album; songs?: Song[] } | undefined =>
placeholderAlbum ? { album: placeholderAlbum } : undefined,
})
return query
}
export const useQueryAlbumList = (type: GetAlbumList2TypeBase, size: number) => {
const fetchAlbumList = useFetchAlbumList()
return useInfiniteQuery(
qk.albumList(type, size),
async context => {
return await fetchAlbumList(size, context.pageParam || 0, type)
},
{
getNextPageParam: (lastPage, allPages) => {
if (lastPage.length === 0) {
return
}
return allPages.length * size
},
cacheTime: 0,
},
)
}
export const useQuerySearchResults = (params: Search3Params) => {
const fetchSearchResults = useFetchSearchResults()
const query = useInfiniteQuery(
qk.search(params.query, params.artistCount, params.albumCount, params.songCount),
async context => {
return await fetchSearchResults({
...params,
artistOffset: context.pageParam?.artistOffset || 0,
albumOffset: context.pageParam?.albumOffset || 0,
songOffset: context.pageParam?.songOffset || 0,
})
},
{
getNextPageParam: (lastPage, allPages) => {
if (lastPage.albums.length + lastPage.artists.length + lastPage.songs.length === 0) {
return
}
return {
artistOffset: allPages.reduce((acc, val) => (acc += val.artists.length), 0),
albumOffset: allPages.reduce((acc, val) => (acc += val.albums.length), 0),
songOffset: allPages.reduce((acc, val) => (acc += val.songs.length), 0),
}
},
cacheTime: 1000 * 60,
enabled: !!params.query && params.query.length > 1,
},
)
return query
}
export const useQueryHomeLists = (types: GetAlbumList2TypeBase[], size: number) => {
const fetchAlbumList = useFetchAlbumList()
const listQueries = useQueries(
types.map(type => {
return {
queryKey: qk.albumList(type, size),
queryFn: async () => {
const albums = await fetchAlbumList(size, 0, type as GetAlbumList2TypeBase)
return { type, albums }
},
}
}),
)
return listQueries
}
export const useStar = (id: string, type: StarrableItemType) => {
const fetchStar = useFetchStar()
const fetchUnstar = useFetchUnstar()
const fetchSong = useFetchSong()
const fetchAlbum = useFetchAlbum()
const fetchArtist = useFetchArtist()
const query = useQuery(
qk.starredItems(id),
async () => {
switch (type) {
case 'album':
console.log('fetch album starred', id)
return !!(await fetchAlbum(id)).album.starred
case 'artist':
console.log('fetch artist starred', id)
return !!(await fetchArtist(id)).artist.starred
default:
console.log('fetch song starred', id)
return !!(await fetchSong(id)).starred
}
},
{
cacheTime: Infinity,
staleTime: Infinity,
},
)
const toggle = useMutation(
() => {
const params: StarParams = {
id: type === 'song' ? id : undefined,
albumId: type === 'album' ? id : undefined,
artistId: type === 'artist' ? id : undefined,
}
return !query.data ? fetchStar(params) : fetchUnstar(params)
},
{
onMutate: () => {
queryClient.setQueryData<boolean>(qk.starredItems(id), !query.data)
},
onSuccess: () => {
if (type === 'album') {
queryClient.invalidateQueries(qk.albumList('starred'))
}
},
},
)
return { query, toggle }
}
export const useQueryExistingFile = (itemType: CacheItemTypeKey, itemId: string) => {
const fetchExistingFile = useFetchExistingFile()
return useQuery(qk.existingFiles(itemType, itemId), () => fetchExistingFile({ itemType, itemId }), {
staleTime: Infinity,
cacheTime: Infinity,
notifyOnChangeProps: ['data', 'isFetched'],
})
}
export const useQueryCoverArtPath = (coverArt = '-1', size: CacheImageSize = 'thumbnail') => {
const fetchFile = useFetchFile()
const client = useStore(store => store.client)
const itemType: CacheItemTypeKey = size === 'original' ? 'coverArt' : 'coverArtThumb'
const existing = useQueryExistingFile(itemType, coverArt)
const query = useQuery(
qk.coverArt(coverArt, size),
async () => {
if (!client) {
return
}
const fromUrl = client.getCoverArtUri({ id: coverArt, size: itemType === 'coverArtThumb' ? '256' : undefined })
return await fetchFile({ itemType, itemId: coverArt, fromUrl, expectedContentType: 'image' })
},
{
enabled: existing.isFetched && !existing.data && !!client,
staleTime: Infinity,
cacheTime: Infinity,
},
)
return { ...query, data: existing.data || query.data, isExistingFetching: existing.isFetching }
}
export const useQueryArtistArtPath = (artistId: string, size: CacheImageSize = 'thumbnail') => {
const fetchFile = useFetchFile()
const client = useStore(store => store.client)
const { data: artistInfo } = useQueryArtistInfo(artistId)
const itemType: CacheItemTypeKey = size === 'original' ? 'artistArt' : 'artistArtThumb'
const existing = useQueryExistingFile(itemType, artistId)
const query = useQuery(
qk.artistArt(artistId, size),
async () => {
if (!client || !artistInfo?.smallImageUrl || !artistInfo?.largeImageUrl) {
return
}
const fromUrl = itemType === 'artistArtThumb' ? artistInfo.smallImageUrl : artistInfo.largeImageUrl
return await fetchFile({ itemType, itemId: artistId, fromUrl, expectedContentType: 'image' })
},
{
enabled:
existing.isFetched &&
!existing.data &&
!!client &&
(!!artistInfo?.smallImageUrl || !!artistInfo?.largeImageUrl),
staleTime: Infinity,
cacheTime: Infinity,
},
)
return { ...query, data: existing.data || query.data, isExistingFetching: existing.isFetching }
}
export const useQueryAlbumCoverArtPath = (albumId?: string, size: CacheImageSize = 'thumbnail') => {
const fetchAlbum = useFetchAlbum()
const query = useQuery(
qk.albumCoverArt(albumId || '-1'),
async () => (await fetchAlbum(albumId || '-1')).album.coverArt,
{
enabled: !!albumId,
staleTime: Infinity,
cacheTime: Infinity,
},
)
return useQueryCoverArtPath(query.data, size)
}
-75
View File
@@ -1,75 +0,0 @@
import { useReset } from '@app/hooks/trackplayer'
import { CacheItemTypeKey } from '@app/models/cache'
import queryClient from '@app/query/queryClient'
import { useStore, useStoreDeep } from '@app/state/store'
import cacheDir from '@app/util/cacheDir'
import RNFS from 'react-native-fs'
import qk from '@app/query/queryKeys'
export const useSwitchActiveServer = () => {
const activeServerId = useStore(store => store.settings.activeServerId)
const setActiveServer = useStore(store => store.setActiveServer)
const resetPlayer = useReset()
return async (id: string) => {
if (id === activeServerId) {
return
}
await queryClient.cancelQueries(undefined, { active: true })
await resetPlayer()
queryClient.removeQueries()
setActiveServer(id)
}
}
export const useFirstRun = () => {
return useStore(store => Object.keys(store.settings.servers).length === 0)
}
export const useResetImageCache = () => {
const serverIds = useStoreDeep(store => Object.keys(store.settings.servers))
const changeCacheBuster = useStore(store => store.changeCacheBuster)
const setDisableMusicTabs = useStore(store => store.setDisableMusicTabs)
return async () => {
setDisableMusicTabs(true)
try {
// disable/invalidate queries
await Promise.all([
queryClient.cancelQueries(qk.artistArt(), { active: true }),
queryClient.cancelQueries(qk.coverArt(), { active: true }),
queryClient.cancelQueries(qk.existingFiles(), { active: true }),
queryClient.invalidateQueries(qk.artistArt(), { refetchActive: false }),
queryClient.invalidateQueries(qk.coverArt(), { refetchActive: false }),
queryClient.invalidateQueries(qk.existingFiles(), { refetchActive: false }),
])
// delete all images
const itemTypes: CacheItemTypeKey[] = ['artistArt', 'artistArtThumb', 'coverArt', 'coverArtThumb']
await Promise.all(
serverIds.flatMap(id =>
itemTypes.map(async type => {
const dir = cacheDir(id, type)
try {
await RNFS.unlink(dir)
} catch {}
}),
),
)
// change cacheBuster
changeCacheBuster()
} finally {
setDisableMusicTabs(false)
// enable queries
await Promise.all([
queryClient.refetchQueries(qk.existingFiles(), { active: true }),
queryClient.refetchQueries(qk.artistArt(), { active: true }),
queryClient.refetchQueries(qk.coverArt(), { active: true }),
])
}
}
}
-139
View File
@@ -1,139 +0,0 @@
import { Song } from '@app/models/library'
import { QueueContextType, TrackExt } from '@app/models/trackplayer'
import queryClient from '@app/query/queryClient'
import QueueEvents from '@app/trackplayer/QueueEvents'
import { useStore, useStoreDeep } from '@app/state/store'
import { getQueue, SetQueueOptions, trackPlayerCommands } from '@app/state/trackplayer'
import userAgent from '@app/util/userAgent'
import TrackPlayer from 'react-native-track-player'
import qk from '@app/query/queryKeys'
export const usePlay = () => {
return () => trackPlayerCommands.enqueue(() => TrackPlayer.play())
}
export const usePause = () => {
return () => trackPlayerCommands.enqueue(() => TrackPlayer.pause())
}
export const usePrevious = () => {
return () =>
trackPlayerCommands.enqueue(async () => {
const [current] = await Promise.all([await TrackPlayer.getCurrentTrack(), await getQueue()])
if (current > 0) {
await TrackPlayer.skipToPrevious()
} else {
await TrackPlayer.seekTo(0)
}
await TrackPlayer.play()
})
}
export const useNext = () => {
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()
} else {
await TrackPlayer.skipToNext()
await TrackPlayer.play()
}
})
}
export const useSkipTo = () => {
return (track: number) =>
trackPlayerCommands.enqueue(async () => {
const queue = await getQueue()
if (track < 0 || track >= queue.length) {
return
}
await TrackPlayer.skip(track)
await TrackPlayer.play()
})
}
export const useSeekTo = () => {
return (position: number) =>
trackPlayerCommands.enqueue(async () => {
await TrackPlayer.seekTo(position)
})
}
export const useReset = (enqueue = true) => {
const resetStore = useStore(store => store.resetTrackPlayerState)
const reset = async () => {
await TrackPlayer.reset()
resetStore()
}
return enqueue ? () => trackPlayerCommands.enqueue(reset) : reset
}
export const useIsPlaying = (contextId: string | undefined, track: number) => {
const queueContextId = useStore(store => store.queueContextId)
const currentTrackIdx = useStore(store => store.currentTrackIdx)
const shuffleOrder = useStoreDeep(store => store.shuffleOrder)
if (contextId === undefined) {
return track === currentTrackIdx
}
if (shuffleOrder) {
const shuffledTrack = shuffleOrder.findIndex(i => i === track)
track = shuffledTrack !== undefined ? shuffledTrack : -1
}
return contextId === queueContextId && track === currentTrackIdx
}
export function mapSongToTrackExt(song: Song): TrackExt {
return {
id: song.id,
title: song.title,
artist: song.artist || 'Unknown Artist',
album: song.album || 'Unknown Album',
url: useStore.getState().buildStreamUri(song.id),
artwork: require('@res/fallback.png'),
userAgent,
duration: song.duration,
artistId: song.artistId,
albumId: song.albumId,
track: song.track,
discNumber: song.discNumber,
}
}
export const useSetQueue = (type: QueueContextType, songs?: Song[]) => {
const _setQueue = useStore(store => store.setQueue)
const contextId = `${type}-${songs?.map(s => s.id).join('-')}`
const setQueue = async (options: SetQueueOptions) => {
if (!songs || songs.length === 0) {
return
}
const queue = songs.map(mapSongToTrackExt)
const first = queue[options.playTrack || 0]
if (!first.albumId) {
first.artwork = require('@res/fallback.png')
} else {
const albumCoverArt = queryClient.getQueryData<string>(qk.albumCoverArt(first.albumId))
const existingFile = queryClient.getQueryData<string>(qk.existingFiles('coverArtThumb', albumCoverArt))
const downloadFile = queryClient.getQueryData<string>(qk.coverArt(albumCoverArt, 'thumbnail'))
if (existingFile || downloadFile) {
first.artwork = `file://${existingFile || downloadFile}`
}
}
await _setQueue({ queue, type, contextId, ...options })
QueueEvents.emit('set', { queue })
}
return { setQueue, contextId }
}
-15
View File
@@ -1,15 +0,0 @@
import { useStore } from '@app/state/store'
const useClient = () => {
const client = useStore(store => store.client)
return () => {
if (!client) {
throw new Error('no client!')
}
return client
}
}
export default useClient
-64
View File
@@ -1,64 +0,0 @@
import { BackendModule, LanguageDetectorAsyncModule } from 'i18next'
import path from 'path'
import RNFS from 'react-native-fs'
import * as RNLocalize from 'react-native-localize'
import _ from 'lodash'
const I18N_ASSETS_DIR = path.join('custom', 'i18n')
const cache: {
[language: string]: {
[key: string]: any
}
} = {}
async function loadTranslation(language: string) {
const text = await RNFS.readFileAssets(path.join(I18N_ASSETS_DIR, `${language}.json`), 'utf8')
return JSON.parse(text)
}
async function readTranslation(language: string, namespace: string) {
if (!cache[language]) {
cache[language] = await loadTranslation(language)
}
return namespace === 'translation' ? cache[language] : _.get(cache[language], namespace)
}
export const backend = {
type: 'backend',
init: () => {},
read: async (language, namespace, callback) => {
try {
callback(null, await readTranslation(language, namespace))
} catch (err) {
callback(err as any, null)
}
},
} as BackendModule
export const languageDetector = {
type: 'languageDetector',
async: true,
detect: async callback => {
try {
const languageTags = (await RNFS.readDirAssets(I18N_ASSETS_DIR))
.map(f => f.name)
.filter(n => n.endsWith('.json'))
.map(n => n.slice(0, -5))
console.log('translations available:', languageTags)
console.log(
'locales list:',
RNLocalize.getLocales().map(l => l.languageTag),
)
console.log('best language:', RNLocalize.findBestAvailableLanguage(languageTags)?.languageTag)
callback(RNLocalize.findBestAvailableLanguage(languageTags)?.languageTag)
} catch {
callback(undefined)
}
},
init: () => {},
cacheUserLanguage: () => {},
} as LanguageDetectorAsyncModule
-38
View File
@@ -1,38 +0,0 @@
import { Album, Playlist, Artist, Song } from './library'
export enum CacheItemType {
coverArt = 'coverArt',
coverArtThumb = 'coverArtThumb',
artistArt = 'artistArt',
artistArtThumb = 'artistArtThumb',
}
export type CacheItemTypeKey = keyof typeof CacheItemType
export type CacheImageSize = 'thumbnail' | 'original'
export type CacheFile = {
path: string
date: number
permanent: boolean
}
export type CacheRequest = {
progress: number
promise?: Promise<void>
}
export type DownloadedAlbum = Album & {
songs: string[]
}
export type DownloadedPlaylist = Playlist & {
songs: string[]
}
export type DownloadedArtist = Artist & {
topSongs: string[]
albums: string[]
}
export type DownloadedSong = Song
-5
View File
@@ -1,5 +0,0 @@
export class NoClientError extends Error {
constructor() {
super('no client in state')
}
}
-64
View File
@@ -1,64 +0,0 @@
export interface Artist {
itemType: 'artist'
id: string
name: string
starred?: number
coverArt?: string
}
export interface ArtistInfo {
id: string
smallImageUrl?: string
largeImageUrl?: string
}
export interface Album {
itemType: 'album'
id: string
name: string
artist?: string
artistId?: string
starred?: number
coverArt?: string
year?: number
}
export interface Playlist {
itemType: 'playlist'
id: string
name: string
comment?: string
coverArt?: string
}
export interface Song {
itemType: 'song'
id: string
album?: string
albumId?: string
artist?: string
artistId?: string
title: string
track?: number
discNumber?: number
duration?: number
starred?: number
playCount?: number
userRating?: number
averageRating?: number
}
export interface SearchResults {
artists: Artist[]
albums: Album[]
songs: Song[]
}
export type StarrableItemType = 'album' | 'song' | 'artist'
export type ListableItem = Album | Song | Artist | Playlist
export interface AlbumCoverArt {
albumId: string
coverArt?: string
}
-84
View File
@@ -1,84 +0,0 @@
import {
AlbumID3Element,
ArtistID3Element,
ArtistInfo2Element,
ChildElement,
PlaylistElement,
} from '@app/subsonic/elements'
import { Album, Artist, ArtistInfo, Playlist, Song } from './library'
import { TrackExt } from './trackplayer'
export function mapArtist(artist: ArtistID3Element): Artist {
return {
itemType: 'artist',
id: artist.id,
name: artist.name,
starred: artist.starred?.getTime(),
coverArt: artist.coverArt,
}
}
export function mapArtistInfo(id: string, info: ArtistInfo2Element): ArtistInfo {
return {
id,
smallImageUrl: info.smallImageUrl,
largeImageUrl: info.largeImageUrl,
}
}
export function mapAlbum(album: AlbumID3Element): Album {
return {
itemType: 'album',
id: album.id,
name: album.name,
artist: album.artist,
artistId: album.artistId,
starred: album.starred?.getTime(),
coverArt: album.coverArt,
year: album.year,
}
}
export function mapPlaylist(playlist: PlaylistElement): Playlist {
return {
itemType: 'playlist',
id: playlist.id,
name: playlist.name,
comment: playlist.comment,
coverArt: playlist.coverArt,
}
}
export function mapSong(song: ChildElement): Song {
return {
itemType: 'song',
id: song.id,
album: song.album,
albumId: song.albumId,
artist: song.artist,
artistId: song.artistId,
title: song.title,
track: song.track,
discNumber: song.discNumber,
duration: song.duration,
starred: song.starred?.getTime(),
playCount: song.playCount,
averageRating: song.averageRating,
userRating: song.userRating,
}
}
export function mapTrackExtToSong(track: TrackExt): Song {
return {
itemType: 'song',
id: track.id,
title: track.title as string,
artist: track.artist,
album: track.album,
duration: track.duration,
artistId: track.artistId,
albumId: track.albumId,
track: track.track,
discNumber: track.discNumber,
}
}
-32
View File
@@ -1,32 +0,0 @@
import { GetAlbumList2Type } from '@app/subsonic/params'
export type Server = (TokenPassword | PlainPassword) & {
id: string
address: string
username: string
usePlainPassword: boolean
}
interface PlainPassword {
usePlainPassword: true
plainPassword: string
}
interface TokenPassword {
usePlainPassword: false
token: string
salt: string
}
export interface AlbumFilterSettings {
type: GetAlbumList2Type
fromYear: number
toYear: number
genre: string
}
export type ArtistFilterType = 'random' | 'starred' | 'alphabeticalByName'
export interface ArtistFilterSettings {
type: ArtistFilterType
}
-19
View File
@@ -1,19 +0,0 @@
export interface ById<T> {
[id: string]: T
}
export type OneToMany = ById<string[]>
export interface OrderedById<T> {
byId: ById<T>
allIds: string[]
}
export interface PaginatedList {
[offset: number]: string[]
}
export interface CollectionById<T extends { id: string }> {
byId: ById<T>
allIds: string[]
}
-18
View File
@@ -1,18 +0,0 @@
import { Track } from 'react-native-track-player'
export type TrackExt = Track & {
id: string
coverArt?: string
artistId?: string
albumId?: string
track?: number
discNumber?: number
}
export type Progress = {
position: number
duration: number
buffered: number
}
export type QueueContextType = 'album' | 'playlist' | 'song' | 'artist'
-120
View File
@@ -1,120 +0,0 @@
import NowPlayingBar from '@app/components/NowPlayingBar'
import PressableOpacity from '@app/components/PressableOpacity'
import { useFirstRun } from '@app/hooks/settings'
import colors from '@app/styles/colors'
import dimensions from '@app/styles/dimensions'
import font from '@app/styles/font'
import { bottomTabIcons, OutlineFillIcon } from '@app/styles/icons'
import { BottomTabBarProps } from '@react-navigation/bottom-tabs'
import { BottomTabNavigationEventMap } from '@react-navigation/bottom-tabs/lib/typescript/src/types'
import { NavigationHelpers, ParamListBase } from '@react-navigation/native'
import React from 'react'
import { Image, ImageStyle, StyleSheet, Text, View } from 'react-native'
const BottomTabButton = React.memo<{
routeKey: string
label: string
name: string
isFocused: boolean
icon: OutlineFillIcon
navigation: NavigationHelpers<ParamListBase, BottomTabNavigationEventMap>
disabled?: boolean
}>(({ routeKey, label, name, isFocused, icon, navigation, disabled }) => {
const firstRun = useFirstRun()
disabled = !!disabled || (firstRun && name !== 'settings')
const onPress = () => {
const event = navigation.emit({
type: 'tabPress',
target: routeKey,
canPreventDefault: true,
})
if (!isFocused && !event.defaultPrevented) {
navigation.navigate(name)
}
}
const focusColor = isFocused ? colors.text.primary : colors.text.secondary
const imgSource = isFocused ? icon.fill : icon.outline
const imgFocusStyle: ImageStyle = {
tintColor: focusColor,
opacity: isFocused ? 1 : 0.6,
}
const imgStyle = [styles.image, imgFocusStyle]
const textStyle = [styles.text, { color: focusColor }]
return (
<PressableOpacity onPress={onPress} style={styles.button} disabled={disabled}>
<Image source={imgSource} style={imgStyle} fadeDuration={0} />
<Text style={textStyle} numberOfLines={1} ellipsizeMode="clip">
{label}
</Text>
</PressableOpacity>
)
})
const BottomTabBar: React.FC<BottomTabBarProps> = ({ state, descriptors, navigation }) => (
<View>
<NowPlayingBar />
<View style={styles.container}>
{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
let iconKey = route.name
let disabled = false
if (route.name.endsWith('-disabled')) {
iconKey = route.name.split('-')[0]
disabled = true
}
return (
<BottomTabButton
key={route.key}
routeKey={route.key}
label={label}
name={route.name}
isFocused={state.index === index}
icon={bottomTabIcons[iconKey]}
navigation={navigation}
disabled={disabled}
/>
)
})}
</View>
</View>
)
const styles = StyleSheet.create({
container: {
height: dimensions.tabBar,
backgroundColor: colors.gradient.high,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-around',
paddingHorizontal: 28,
},
button: {
alignItems: 'center',
flexGrow: 1,
flex: 1,
height: '100%',
},
image: {
height: 26,
width: 26,
},
text: {
fontSize: 10,
fontFamily: font.regular,
},
})
export default BottomTabBar
-209
View File
@@ -1,209 +0,0 @@
import { withSuspense } from '@app/components/withSuspense'
import { useFirstRun } from '@app/hooks/settings'
import { Album, Playlist } from '@app/models/library'
import BottomTabBar from '@app/navigation/BottomTabBar'
import LibraryTopTabNavigator from '@app/navigation/LibraryTopTabNavigator'
import ArtistView from '@app/screens/ArtistView'
import Home from '@app/screens/Home'
import Search from '@app/screens/Search'
import SearchResultsView from '@app/screens/SearchResultsView'
import ServerView from '@app/screens/ServerView'
import SettingsView from '@app/screens/Settings'
import SongListView from '@app/screens/SongListView'
import WebViewScreen from '@app/screens/WebViewScreen'
import { useStore } from '@app/state/store'
import colors from '@app/styles/colors'
import font from '@app/styles/font'
import { BottomTabNavigationProp, createBottomTabNavigator } from '@react-navigation/bottom-tabs'
import { RouteProp, StackActions } from '@react-navigation/native'
import React, { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { StyleSheet } from 'react-native'
import { createNativeStackNavigator, NativeStackNavigationProp } from 'react-native-screens/native-stack'
type TabStackParamList = {
main: undefined
album: { id: string; title: string; album?: Album }
artist: { id: string; title: string }
playlist: { id: string; title: string; playlist?: Playlist }
results: { query: string; type: 'album' | 'song' | 'artist' }
}
type AlbumScreenNavigationProp = NativeStackNavigationProp<TabStackParamList, 'album'>
type AlbumScreenRouteProp = RouteProp<TabStackParamList, 'album'>
type AlbumScreenProps = {
route: AlbumScreenRouteProp
navigation: AlbumScreenNavigationProp
}
const AlbumScreen: React.FC<AlbumScreenProps> = ({ route }) => <SongListView {...route.params} type="album" />
type ArtistScreenNavigationProp = NativeStackNavigationProp<TabStackParamList, 'artist'>
type ArtistScreenRouteProp = RouteProp<TabStackParamList, 'artist'>
type ArtistScreenProps = {
route: ArtistScreenRouteProp
navigation: ArtistScreenNavigationProp
}
const ArtistScreen: React.FC<ArtistScreenProps> = ({ route }) => <ArtistView {...route.params} />
type PlaylistScreenNavigationProp = NativeStackNavigationProp<TabStackParamList, 'playlist'>
type PlaylistScreenRouteProp = RouteProp<TabStackParamList, 'playlist'>
type PlaylistScreenProps = {
route: PlaylistScreenRouteProp
navigation: PlaylistScreenNavigationProp
}
const PlaylistScreen: React.FC<PlaylistScreenProps> = ({ route }) => <SongListView {...route.params} type="playlist" />
type ResultsScreenNavigationProp = NativeStackNavigationProp<TabStackParamList, 'results'>
type ResultsScreenRouteProp = RouteProp<TabStackParamList, 'results'>
type ResultsScreenProps = {
route: ResultsScreenRouteProp
navigation: ResultsScreenNavigationProp
}
const ResultsScreen: React.FC<ResultsScreenProps> = ({ route }) => <SearchResultsView {...route.params} />
const styles = StyleSheet.create({
stackheaderStyle: {
backgroundColor: colors.gradient.high,
// backgroundColor: 'transparent',
},
stackheaderTitleStyle: {
fontSize: 18,
fontFamily: font.semiBold,
color: colors.text.primary,
},
})
const itemScreenOptions = {
title: '',
headerStyle: styles.stackheaderStyle,
headerHideShadow: true,
headerTintColor: 'white',
headerTitleStyle: styles.stackheaderTitleStyle,
// headerTranslucent: true,
}
function createTabStackNavigator(Component: React.ComponentType<any>) {
const Stack = createNativeStackNavigator<TabStackParamList>()
const Navigator: React.FC<{ navigation: BottomTabNavigationProp<{ a: undefined }, 'a'> }> = ({ navigation }) => {
useEffect(() => {
return navigation.addListener('tabPress', () => {
navigation.dispatch(StackActions.popToTop())
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
<Stack.Navigator initialRouteName="main">
<Stack.Screen name="main" component={Component} options={{ headerShown: false }} />
<Stack.Screen name="album" component={AlbumScreen} options={{ headerShown: false }} />
<Stack.Screen name="artist" component={ArtistScreen} options={{ headerShown: false }} />
<Stack.Screen name="playlist" component={PlaylistScreen} options={{ headerShown: false }} />
<Stack.Screen name="results" component={ResultsScreen} options={itemScreenOptions} />
</Stack.Navigator>
)
}
return Navigator
}
const HomeTab = createTabStackNavigator(Home)
const LibraryTab = createTabStackNavigator(LibraryTopTabNavigator)
const SearchTab = createTabStackNavigator(Search)
type SettingsStackParamList = {
main: undefined
server?: { id?: string; title?: string }
web: { uri: string; title?: string }
}
type ServerScreenNavigationProp = NativeStackNavigationProp<SettingsStackParamList, 'server'>
type ServerScreenRouteProp = RouteProp<SettingsStackParamList, 'server'>
type ServerScreenProps = {
route: ServerScreenRouteProp
navigation: ServerScreenNavigationProp
}
const ServerScreen: React.FC<ServerScreenProps> = ({ route }) => (
<ServerView id={route.params?.id} title={route.params?.title} />
)
type WebScreenNavigationProp = NativeStackNavigationProp<SettingsStackParamList, 'web'>
type WebScreenRouteProp = RouteProp<SettingsStackParamList, 'web'>
type WebScreenProps = {
route: WebScreenRouteProp
navigation: WebScreenNavigationProp
}
const WebScreen: React.FC<WebScreenProps> = ({ route }) => (
<WebViewScreen uri={route.params.uri} title={route.params.title} />
)
const SettingsStack = createNativeStackNavigator()
const SettingsTab = () => {
return (
<SettingsStack.Navigator initialRouteName="main">
<SettingsStack.Screen name="main" component={SettingsView} options={{ headerShown: false }} />
<SettingsStack.Screen
name="server"
component={ServerScreen}
options={{
headerStyle: styles.stackheaderStyle,
headerHideShadow: true,
headerTintColor: 'white',
headerTitleStyle: styles.stackheaderTitleStyle,
}}
/>
<SettingsStack.Screen
name="web"
component={WebScreen}
options={{
headerStyle: styles.stackheaderStyle,
headerHideShadow: true,
headerTintColor: 'white',
headerTitleStyle: styles.stackheaderTitleStyle,
}}
/>
</SettingsStack.Navigator>
)
}
const Tab = createBottomTabNavigator()
const BottomTabNavigator = withSuspense(() => {
const { t } = useTranslation()
const firstRun = useFirstRun()
const disableMusicTabs = useStore(store => store.disableMusicTabs)
return (
<Tab.Navigator tabBar={BottomTabBar} initialRouteName={firstRun ? 'settings' : 'home'}>
{disableMusicTabs ? (
<>
<Tab.Screen name="home-disabled" children={() => null} options={{ tabBarLabel: t('navigation.tabs.home') }} />
<Tab.Screen
name="library-disabled"
children={() => null}
options={{ tabBarLabel: t('navigation.tabs.library') }}
/>
<Tab.Screen
name="search-disabled"
children={() => null}
options={{ tabBarLabel: t('navigation.tabs.search') }}
/>
</>
) : (
<>
<Tab.Screen name="home" component={HomeTab} options={{ tabBarLabel: t('navigation.tabs.home') }} />
<Tab.Screen name="library" component={LibraryTab} options={{ tabBarLabel: t('navigation.tabs.library') }} />
<Tab.Screen name="search" component={SearchTab} options={{ tabBarLabel: t('navigation.tabs.search') }} />
</>
)}
<Tab.Screen name="settings" component={SettingsTab} options={{ tabBarLabel: t('navigation.tabs.settings') }} />
</Tab.Navigator>
)
})
export default BottomTabNavigator
-67
View File
@@ -1,67 +0,0 @@
import { withSuspense } from '@app/components/withSuspense'
import AlbumsTab from '@app/screens/LibraryAlbums'
import ArtistsTab from '@app/screens/LibraryArtists'
import PlaylistsTab from '@app/screens/LibraryPlaylists'
import colors from '@app/styles/colors'
import dimensions from '@app/styles/dimensions'
import font from '@app/styles/font'
import { createMaterialTopTabNavigator } from '@react-navigation/material-top-tabs'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { StyleSheet } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
const Tab = createMaterialTopTabNavigator()
const LibraryTopTabNavigator = withSuspense(() => {
const { t } = useTranslation()
const marginTop = useSafeAreaInsets().top
return (
<Tab.Navigator
tabBarOptions={{
style: [styles.tabBar, { marginTop }],
labelStyle: styles.tablabelStyle,
indicatorStyle: styles.tabindicatorStyle,
}}
initialRouteName="albums">
<Tab.Screen
name="albums"
component={AlbumsTab}
options={{ tabBarLabel: t('resources.album.name', { count: 2 }) }}
/>
<Tab.Screen
name="artists"
component={ArtistsTab}
options={{ tabBarLabel: t('resources.artist.name', { count: 2 }) }}
/>
<Tab.Screen
name="playlists"
component={PlaylistsTab}
options={{ tabBarLabel: t('resources.playlist.name', { count: 2 }) }}
/>
</Tab.Navigator>
)
})
const styles = StyleSheet.create({
tabBar: {
height: dimensions.header,
backgroundColor: colors.gradient.high,
elevation: 0,
justifyContent: 'center',
},
tablabelStyle: {
fontSize: 18,
fontFamily: font.semiBold,
color: colors.text.primary,
textTransform: null as any,
marginTop: 0,
marginHorizontal: 2,
},
tabindicatorStyle: {
backgroundColor: colors.text.primary,
},
})
export default LibraryTopTabNavigator
-63
View File
@@ -1,63 +0,0 @@
import { withSuspense } from '@app/components/withSuspense'
import BottomTabNavigator from '@app/navigation/BottomTabNavigator'
import NowPlayingQueue from '@app/screens/NowPlayingQueue'
import NowPlayingView from '@app/screens/NowPlayingView'
import colors from '@app/styles/colors'
import font from '@app/styles/font'
import { DarkTheme, NavigationContainer } from '@react-navigation/native'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { createNativeStackNavigator } from 'react-native-screens/native-stack'
const NowPlayingStack = createNativeStackNavigator()
const NowPlayingNavigator = withSuspense(() => {
const { t } = useTranslation()
return (
<NowPlayingStack.Navigator>
<NowPlayingStack.Screen name="main" component={NowPlayingView} options={{ headerShown: false }} />
<NowPlayingStack.Screen
name="queue"
component={NowPlayingQueue}
options={{
title: t('resources.queue.name', { count: 1 }),
headerStyle: {
backgroundColor: colors.gradient.high,
},
headerTitleStyle: {
fontSize: 18,
fontFamily: font.semiBold,
color: colors.text.primary,
},
headerHideShadow: true,
headerTintColor: 'white',
}}
/>
</NowPlayingStack.Navigator>
)
})
const RootStack = createNativeStackNavigator()
const theme = { ...DarkTheme }
theme.colors.background = colors.gradient.high
const RootNavigator = () => (
<NavigationContainer
theme={theme}
linking={{
prefixes: ['trackplayer'],
}}>
<RootStack.Navigator
screenOptions={{
headerShown: false,
}}
initialRouteName="top">
<RootStack.Screen name="top" component={BottomTabNavigator} />
<RootStack.Screen name="now-playing" component={NowPlayingNavigator} />
</RootStack.Navigator>
</NavigationContainer>
)
export default RootNavigator
-174
View File
@@ -1,174 +0,0 @@
import useClient from '@app/hooks/useClient'
import { Album, Playlist, Song } from '@app/models/library'
import { mapAlbum, mapArtist, mapArtistInfo, mapPlaylist, mapSong } from '@app/models/map'
import queryClient from '@app/query/queryClient'
import qk from '@app/query/queryKeys'
import { SubsonicApiClient } from '@app/subsonic/api'
import { GetAlbumList2TypeBase, Search3Params, StarParams } from '@app/subsonic/params'
import { mapCollectionById } from '@app/util/state'
function cacheStarredData<T extends { id: string; starred?: undefined | any }>(item: T) {
queryClient.setQueryData<boolean>(qk.starredItems(item.id), !!item.starred)
}
function cacheAlbumCoverArtData<T extends { id: string; coverArt?: string }>(item: T) {
queryClient.setQueryData<string | undefined>(qk.albumCoverArt(item.id), item.coverArt)
}
export const useFetchArtists = () => {
const client = useClient()
return async () => {
const res = await client().getArtists()
res.data.artists.forEach(cacheStarredData)
return mapCollectionById(res.data.artists, mapArtist)
}
}
export const useFetchArtist = () => {
const client = useClient()
return async (id: string) => {
const res = await client().getArtist({ id })
cacheStarredData(res.data.artist)
res.data.albums.forEach(cacheStarredData)
res.data.albums.forEach(cacheAlbumCoverArtData)
return {
artist: mapArtist(res.data.artist),
albums: res.data.albums.map(mapAlbum),
}
}
}
export const useFetchArtistInfo = () => {
const client = useClient()
return async (id: string) => {
const res = await client().getArtistInfo2({ id })
return mapArtistInfo(id, res.data.artistInfo)
}
}
export const useFetchArtistTopSongs = () => {
const client = useClient()
return async (artistName: string) => {
const res = await client().getTopSongs({ artist: artistName })
res.data.songs.forEach(cacheStarredData)
return res.data.songs.map(mapSong)
}
}
export const useFetchPlaylists = () => {
const client = useClient()
return async () => {
const res = await client().getPlaylists()
return mapCollectionById(res.data.playlists, mapPlaylist)
}
}
export const useFetchPlaylist = () => {
const client = useClient()
return async (id: string): Promise<{ playlist: Playlist; songs?: Song[] }> => {
const res = await client().getPlaylist({ id })
res.data.playlist.songs.forEach(cacheStarredData)
return {
playlist: mapPlaylist(res.data.playlist),
songs: res.data.playlist.songs.map(mapSong),
}
}
}
export async function fetchAlbum(id: string, client: SubsonicApiClient): Promise<{ album: Album; songs?: Song[] }> {
const res = await client.getAlbum({ id })
cacheStarredData(res.data.album)
res.data.songs.forEach(cacheStarredData)
cacheAlbumCoverArtData(res.data.album)
return {
album: mapAlbum(res.data.album),
songs: res.data.songs.map(mapSong),
}
}
export const useFetchAlbum = () => {
const client = useClient()
return async (id: string) => fetchAlbum(id, client())
}
export const useFetchAlbumList = () => {
const client = useClient()
return async (size: number, offset: number, type: GetAlbumList2TypeBase) => {
const res = await client().getAlbumList2({ size, offset, type })
res.data.albums.forEach(cacheStarredData)
res.data.albums.forEach(cacheAlbumCoverArtData)
return res.data.albums.map(mapAlbum)
}
}
export const useFetchSong = () => {
const client = useClient()
return async (id: string) => {
const res = await client().getSong({ id })
cacheStarredData(res.data.song)
return mapSong(res.data.song)
}
}
export const useFetchSearchResults = () => {
const client = useClient()
return async (params: Search3Params) => {
const res = await client().search3(params)
res.data.artists.forEach(cacheStarredData)
res.data.albums.forEach(cacheStarredData)
res.data.songs.forEach(cacheStarredData)
res.data.albums.forEach(cacheAlbumCoverArtData)
return {
artists: res.data.artists.map(mapArtist),
albums: res.data.albums.map(mapAlbum),
songs: res.data.songs.map(mapSong),
}
}
}
export const useFetchStar = () => {
const client = useClient()
return async (params: StarParams) => {
await client().star(params)
return
}
}
export const useFetchUnstar = () => {
const client = useClient()
return async (params: StarParams) => {
await client().unstar(params)
return
}
}
-132
View File
@@ -1,132 +0,0 @@
import { CacheItemTypeKey } from '@app/models/cache'
import queryClient from '@app/query/queryClient'
import qk from '@app/query/queryKeys'
import { useStore } from '@app/state/store'
import cacheDir from '@app/util/cacheDir'
import userAgent from '@app/util/userAgent'
import cd from 'content-disposition'
import mime from 'mime-types'
import path from 'path'
import ReactNativeBlobUtil, { FetchBlobResponse } from 'react-native-blob-util'
import RNFS from 'react-native-fs'
export type FetchExisingFileOptions = {
itemType: CacheItemTypeKey
itemId: string
}
export async function fetchExistingFile(
options: FetchExisingFileOptions,
serverId: string | undefined,
): Promise<string | undefined> {
const { itemType, itemId } = options
const fileDir = cacheDir(serverId, itemType, itemId)
try {
const dir = await RNFS.readDir(fileDir)
console.log('existing file:', dir[0].path)
return dir[0].path
} catch {}
}
export const useFetchExistingFile = () => {
const serverId = useStore(store => store.settings.activeServerId)
return async (options: FetchExisingFileOptions) => fetchExistingFile(options, serverId)
}
function assertMimeType(expected?: string, actual?: string) {
expected = expected?.toLowerCase()
actual = actual?.toLowerCase()
if (!expected || expected === actual) {
return
}
if (!expected.includes(';')) {
actual = actual?.split(';')[0]
}
if (!expected.includes('/')) {
actual = actual?.split('/')[0]
}
if (expected !== actual) {
throw new Error(`Request does not satisfy expected content type. Expected: ${expected} Actual: ${actual}`)
}
}
export type FetchFileOptions = FetchExisingFileOptions & {
fromUrl: string
useCacheBuster?: boolean
expectedContentType?: string
progress?: (received: number, total: number) => void
}
export async function fetchFile(options: FetchFileOptions, serverId: string | undefined): Promise<string> {
let { itemType, itemId, fromUrl, useCacheBuster, expectedContentType, progress } = options
useCacheBuster = useCacheBuster === undefined ? true : useCacheBuster
const fileDir = cacheDir(serverId, itemType, itemId)
const filePathNoExt = path.join(fileDir, useCacheBuster ? useStore.getState().settings.cacheBuster : itemType)
try {
await RNFS.unlink(fileDir)
} catch {}
const headers = { 'User-Agent': userAgent }
// we send a HEAD first for two reasons:
// 1. to follow any redirects and get the actual URL (DownloadManager does not support redirects)
// 2. to obtain the mime-type up front so we can use it for the file extension/validation
const headRes = await fetch(fromUrl, { method: 'HEAD', headers })
if (headRes.status > 399) {
throw new Error(`HTTP status error ${headRes.status}. File: ${itemType} ID: ${itemId}`)
}
const contentType = headRes.headers.get('content-type') || undefined
assertMimeType(expectedContentType, contentType)
const contentDisposition = headRes.headers.get('content-disposition') || undefined
const filename = contentDisposition ? cd.parse(contentDisposition).parameters.filename : undefined
let extension: string | undefined
if (filename) {
extension = path.extname(filename) || undefined
if (extension) {
extension = extension.substring(1)
}
} else if (contentType) {
extension = mime.extension(contentType) || undefined
}
const config = ReactNativeBlobUtil.config({
addAndroidDownloads: {
useDownloadManager: true,
notification: false,
mime: contentType,
description: 'subtracks',
path: extension ? `${filePathNoExt}.${extension}` : filePathNoExt,
},
})
const fetchParams: Parameters<typeof config['fetch']> = ['GET', headRes.url, headers]
let res: FetchBlobResponse
if (progress) {
res = await config.fetch(...fetchParams).progress(progress)
} else {
res = await config.fetch(...fetchParams)
}
const downloadPath = res.path()
queryClient.setQueryData<string>(qk.existingFiles(itemType, itemId), downloadPath)
console.log('downloaded file:', downloadPath)
return downloadPath
}
export const useFetchFile = () => {
const serverId = useStore(store => store.settings.activeServerId)
return async (options: FetchFileOptions) => fetchFile(options, serverId)
}
-5
View File
@@ -1,5 +0,0 @@
import { QueryClient } from 'react-query'
const client = new QueryClient()
export default client
-52
View File
@@ -1,52 +0,0 @@
import { CacheImageSize, CacheItemTypeKey } from '@app/models/cache'
import { GetAlbumList2TypeBase } from '@app/subsonic/params'
const qk = {
starredItems: (id: string) => ['starredItems', id],
albumCoverArt: (id: string) => ['albumCoverArt', id],
artists: 'artists',
artist: (id: string) => ['artist', id],
artistInfo: (id: string) => ['artistInfo', id],
artistTopSongs: (artistName: string) => ['artistTopSongs', artistName],
playlists: 'playlists',
playlist: (id: string) => ['playlist', id],
album: (id: string) => ['album', id],
albumList: (type: GetAlbumList2TypeBase, size?: number) => {
const key: (string | number)[] = ['albumList', type]
size !== undefined && key.push(size)
return key
},
search: (query: string, artistCount?: number, albumCount?: number, songCount?: number) => [
'search',
query,
artistCount,
albumCount,
songCount,
],
coverArt: (coverArt?: string, size?: CacheImageSize) => {
const key: string[] = ['coverArt']
coverArt !== undefined && key.push(coverArt)
size !== undefined && key.push(size)
return key
},
artistArt: (artistId?: string, size?: CacheImageSize) => {
const key: string[] = ['artistArt']
artistId !== undefined && key.push(artistId)
size !== undefined && key.push(size)
return key
},
existingFiles: (type?: CacheItemTypeKey, itemId?: string) => {
const key: string[] = ['existingFiles']
type !== undefined && key.push(type)
itemId !== undefined && key.push(itemId)
return key
},
}
export default qk
-240
View File
@@ -1,240 +0,0 @@
import { AlbumContextPressable } from '@app/components/ContextMenu'
import CoverArt from '@app/components/CoverArt'
import GradientBackground from '@app/components/GradientBackground'
import GradientScrollView from '@app/components/GradientScrollView'
import Header from '@app/components/Header'
import HeaderBar from '@app/components/HeaderBar'
import ListItem from '@app/components/ListItem'
import { withSuspenseMemo } from '@app/components/withSuspense'
import { useQueryArtist, useQueryArtistTopSongs } from '@app/hooks/query'
import { useSetQueue } from '@app/hooks/trackplayer'
import { Album, Song } from '@app/models/library'
import colors from '@app/styles/colors'
import dimensions from '@app/styles/dimensions'
import font from '@app/styles/font'
import { useLayout } from '@react-native-community/hooks'
import { useNavigation } from '@react-navigation/native'
import equal from 'fast-deep-equal/es6/react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native'
import { useAnimatedScrollHandler, useAnimatedStyle, useSharedValue } from 'react-native-reanimated'
const AlbumItem = React.memo<{
album: Album
height: number
width: number
}>(({ album, height, width }) => {
const navigation = useNavigation()
if (height <= 0 || width <= 0) {
return <></>
}
return (
<AlbumContextPressable
album={album}
onPress={() => navigation.navigate('album', { id: album.id, title: album.name, album })}
menuStyle={[styles.albumItem, { width }]}
triggerOuterWrapperStyle={{ width }}>
<CoverArt type="cover" coverArt={album.coverArt} style={{ height, width }} resizeMode="cover" size="thumbnail" />
<Text style={styles.albumTitle}>{album.name}</Text>
<Text style={styles.albumYear}> {album.year ? album.year : ''}</Text>
</AlbumContextPressable>
)
}, equal)
const TopSongs = withSuspenseMemo<{
songs: Song[]
name: string
}>(
({ songs, name }) => {
const { setQueue, contextId } = useSetQueue('artist', songs)
const { t } = useTranslation()
return (
<>
<Header>{t('resources.song.lists.artistTopSongs')}</Header>
{songs.slice(0, 5).map((s, i) => (
<ListItem
key={i}
item={s}
contextId={contextId}
queueId={i}
showArt={true}
subtitle={s.album}
onPress={() => setQueue({ title: name, playTrack: i })}
/>
))}
</>
)
},
null,
equal,
)
const ArtistAlbums = withSuspenseMemo<{
albums: Album[]
}>(
({ albums }) => {
const albumsLayout = useLayout()
const { t } = useTranslation()
const sortedAlbums = [...albums]
.sort((a, b) => a.name.localeCompare(b.name))
.sort((a, b) => (b.year || 0) - (a.year || 0))
const albumSize = albumsLayout.width / 2 - styles.contentContainer.paddingHorizontal / 2
return (
<>
<Header>{t('resources.album.name', { count: 2 })}</Header>
<View style={styles.albums} onLayout={albumsLayout.onLayout}>
{sortedAlbums.map(a => (
<AlbumItem key={a.id} album={a} height={albumSize} width={albumSize} />
))}
</View>
</>
)
},
null,
equal,
)
const ArtistViewFallback = React.memo(() => (
<GradientBackground style={styles.fallback}>
<ActivityIndicator size="large" color={colors.accent} />
</GradientBackground>
))
const ArtistView = React.memo<{ id: string; title: string }>(({ id, title }) => {
const { data: artistData } = useQueryArtist(id)
const { data: topSongs, isError } = useQueryArtistTopSongs(artistData?.artist?.name)
const coverLayout = useLayout()
const headerOpacity = useSharedValue(0)
const onScroll = useAnimatedScrollHandler({
onScroll: event => {
headerOpacity.value = Math.max(0, event.contentOffset.y - 70) / (artistCoverHeight - (70 + dimensions.header))
},
})
const animatedOpacity = useAnimatedStyle(() => {
return {
opacity: headerOpacity.value,
}
})
if (!artistData) {
return <ArtistViewFallback />
}
const { artist, albums } = artistData
return (
<View style={styles.container}>
<HeaderBar title={title} headerStyle={[styles.header, animatedOpacity]} />
<GradientScrollView
onLayout={coverLayout.onLayout}
offset={artistCoverHeight}
style={styles.scroll}
contentContainerStyle={styles.scrollContent}
onScroll={onScroll}>
<CoverArt type="artist" size="original" artistId={artist.id} style={styles.artistCover} resizeMode="cover" />
<View style={styles.titleContainer}>
<Text style={styles.title}>{artist.name}</Text>
</View>
<View style={styles.contentContainer}>
{(topSongs || isError) && artist ? (
topSongs && topSongs.length > 0 ? (
<>
<TopSongs songs={topSongs} name={artist.name} />
<ArtistAlbums albums={albums} />
</>
) : (
<ArtistAlbums albums={albums} />
)
) : (
<ActivityIndicator size="large" color={colors.accent} style={styles.loading} />
)}
</View>
</GradientScrollView>
</View>
)
})
const artistCoverHeight = 350
const styles = StyleSheet.create({
container: {
flex: 1,
},
header: {
position: 'absolute',
zIndex: 1,
},
scroll: {
flex: 1,
},
fallback: {
alignItems: 'center',
paddingTop: 100,
},
scrollContent: {
alignItems: 'center',
},
contentContainer: {
minHeight: artistCoverHeight * 2,
width: '100%',
paddingHorizontal: 20,
},
titleContainer: {
width: '100%',
height: artistCoverHeight,
justifyContent: 'flex-end',
},
title: {
fontFamily: font.bold,
fontSize: 44,
color: colors.text.primary,
textAlign: 'center',
textShadowColor: 'black',
textShadowOffset: { width: 0, height: 0 },
textShadowRadius: 16,
paddingHorizontal: 10,
marginBottom: 10,
},
artistCover: {
position: 'absolute',
height: artistCoverHeight,
width: '100%',
},
albums: {
width: '100%',
flexDirection: 'row',
flexWrap: 'wrap',
alignItems: 'flex-start',
justifyContent: 'space-between',
},
albumItem: {
marginBottom: 20,
},
albumTitle: {
fontFamily: font.semiBold,
fontSize: 14,
color: colors.text.primary,
marginTop: 4,
textAlign: 'center',
},
albumYear: {
color: colors.text.secondary,
fontFamily: font.regular,
textAlign: 'center',
},
loading: {
marginTop: 30,
},
})
export default ArtistView
-154
View File
@@ -1,154 +0,0 @@
import { AlbumContextPressable } from '@app/components/ContextMenu'
import CoverArt from '@app/components/CoverArt'
import GradientScrollView from '@app/components/GradientScrollView'
import Header from '@app/components/Header'
import NothingHere from '@app/components/NothingHere'
import { withSuspenseMemo } from '@app/components/withSuspense'
import { useQueryHomeLists } from '@app/hooks/query'
import { Album } from '@app/models/library'
import { useStoreDeep } from '@app/state/store'
import colors from '@app/styles/colors'
import font from '@app/styles/font'
import { GetAlbumList2TypeBase } from '@app/subsonic/params'
import { useNavigation } from '@react-navigation/native'
import equal from 'fast-deep-equal/es6/react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
const AlbumItem = React.memo<{
album: Album
}>(({ album }) => {
const navigation = useNavigation()
return (
<AlbumContextPressable
album={album}
triggerWrapperStyle={styles.item}
onPress={() => navigation.navigate('album', { id: album.id, title: album.name, album })}>
<CoverArt
type="cover"
coverArt={album.coverArt}
style={{ height: styles.item.width, width: styles.item.width }}
resizeMode="cover"
size="thumbnail"
/>
<Text style={styles.title} numberOfLines={1}>
{album.name}
</Text>
<Text style={styles.subtitle} numberOfLines={1}>
{album.artist}
</Text>
</AlbumContextPressable>
)
}, equal)
const CategoryHeader = withSuspenseMemo<{ type: string }>(({ type }) => {
const { t } = useTranslation()
console.log('type', type, t(`resources.album.lists.${type}`))
return <Header style={styles.header}>{t(`resources.album.lists.${type}`)}</Header>
})
const Category = React.memo<{
type: string
albums: Album[]
}>(({ type, albums }) => {
const Albums = () => (
<ScrollView
horizontal={true}
showsHorizontalScrollIndicator={false}
overScrollMode={'never'}
style={styles.artScroll}
contentContainerStyle={styles.artScrollContent}>
{albums.map(a => (
<AlbumItem key={a.id} album={a} />
))}
</ScrollView>
)
const Nothing = () => (
<View style={styles.nothingHereContent}>
<NothingHere height={160} width={160} />
</View>
)
return (
<View style={styles.category}>
<CategoryHeader type={type} />
{albums.length > 0 ? <Albums /> : <Nothing />}
</View>
)
}, equal)
const Home = () => {
const types = useStoreDeep(store => store.settings.screens.home.listTypes)
const listQueries = useQueryHomeLists(types as GetAlbumList2TypeBase[], 20)
const paddingTop = useSafeAreaInsets().top
return (
<GradientScrollView
style={styles.scroll}
contentContainerStyle={{ paddingTop }}
refreshControl={
<RefreshControl
refreshing={listQueries.some(q => q.isLoading)}
onRefresh={() => listQueries.forEach(q => q.refetch())}
colors={[colors.accent, colors.accentLow]}
progressViewOffset={paddingTop}
/>
}>
<View style={styles.content}>
{types.map(type => {
const query = listQueries.find(list => list.data?.type === type)
return <Category key={type} type={type} albums={query?.data?.albums || []} />
})}
</View>
</GradientScrollView>
)
}
const styles = StyleSheet.create({
scroll: {
flex: 1,
},
content: {
paddingBottom: 20,
},
header: {
paddingHorizontal: 20,
},
category: {
// marginTop: 12,
},
nothingHereContent: {
width: '100%',
height: 190,
justifyContent: 'center',
alignItems: 'center',
},
artScroll: {
height: 190,
},
artScrollContent: {
paddingLeft: 20,
},
item: {
flex: 1,
marginRight: 10,
width: 150,
},
title: {
fontFamily: font.semiBold,
fontSize: 13,
color: colors.text.primary,
marginTop: 4,
},
subtitle: {
fontFamily: font.regular,
fontSize: 12,
color: colors.text.secondary,
},
})
export default Home
-141
View File
@@ -1,141 +0,0 @@
import { AlbumContextPressable } from '@app/components/ContextMenu'
import CoverArt from '@app/components/CoverArt'
import FilterButton from '@app/components/FilterButton'
import GradientFlatList from '@app/components/GradientFlatList'
import { withSuspenseMemo } from '@app/components/withSuspense'
import { useQueryAlbumList } from '@app/hooks/query'
import { Album } from '@app/models/library'
import { useStore, useStoreDeep } from '@app/state/store'
import colors from '@app/styles/colors'
import font from '@app/styles/font'
import { GetAlbumList2TypeBase } from '@app/subsonic/params'
import { useNavigation } from '@react-navigation/native'
import equal from 'fast-deep-equal/es6/react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { StyleSheet, Text, useWindowDimensions, View } from 'react-native'
const AlbumItem = React.memo<{
album: Album
size: number
height: number
}>(({ album, size, height }) => {
const navigation = useNavigation()
if (!album) {
return <></>
}
return (
<AlbumContextPressable
album={album}
menuStyle={[styles.itemMenu, { width: size }]}
triggerWrapperStyle={[styles.itemWrapper, { height }]}
onPress={() => navigation.navigate('album', { id: album.id, title: album.name, album })}>
<CoverArt
type="cover"
coverArt={album.coverArt}
style={{ height: size, width: size }}
resizeMode="cover"
size="thumbnail"
/>
<View style={styles.itemDetails}>
<Text style={styles.title} numberOfLines={1}>
{album.name}
</Text>
<Text style={styles.subtitle} numberOfLines={1}>
{album.artist}
</Text>
</View>
</AlbumContextPressable>
)
}, equal)
const AlbumListRenderItem: React.FC<{
item: { album: Album; size: number; height: number }
}> = ({ item }) => <AlbumItem album={item.album} size={item.size} height={item.height} />
const filterValues: GetAlbumList2TypeBase[] = [
'alphabeticalByName', //
'alphabeticalByArtist',
'newest',
'frequent',
'recent',
'starred',
'random',
]
const AlbumFilterButton = withSuspenseMemo(() => {
const { t } = useTranslation()
const filterType = useStoreDeep(store => store.settings.screens.library.albumsFilter.type)
const setFilterType = useStore(store => store.setLibraryAlbumFilterType)
return (
<FilterButton
data={filterValues.map(value => ({ value, text: t(`resources.album.lists.${value}`) }))}
value={filterType}
onSelect={selection => {
setFilterType(selection as GetAlbumList2TypeBase)
}}
title={t('resources.album.lists.sort')}
/>
)
})
const AlbumsList = () => {
const filterType = useStoreDeep(store => store.settings.screens.library.albumsFilter.type)
const { isLoading, data, fetchNextPage, refetch } = useQueryAlbumList(filterType as GetAlbumList2TypeBase, 300)
const layout = useWindowDimensions()
const size = layout.width / 3 - styles.itemWrapper.marginHorizontal * 2
const height = size + 36
return (
<View style={styles.container}>
<GradientFlatList
data={data ? data.pages.flatMap(albums => albums.map(album => ({ album, size, height }))) : []}
renderItem={AlbumListRenderItem}
keyExtractor={item => item.album.id}
numColumns={3}
removeClippedSubviews={true}
refreshing={isLoading}
onRefresh={refetch}
overScrollMode="never"
onEndReached={() => fetchNextPage()}
onEndReachedThreshold={6}
windowSize={5}
/>
<AlbumFilterButton />
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
itemMenu: {
flex: 1 / 3,
},
itemWrapper: {
marginVertical: 4,
marginHorizontal: 2,
},
itemDetails: {
flex: 1,
},
title: {
fontSize: 12,
fontFamily: font.semiBold,
color: colors.text.primary,
marginTop: 4,
},
subtitle: {
fontSize: 11,
fontFamily: font.regular,
color: colors.text.secondary,
},
})
export default React.memo(AlbumsList)
-93
View File
@@ -1,93 +0,0 @@
import FilterButton from '@app/components/FilterButton'
import GradientFlatList from '@app/components/GradientFlatList'
import ListItem from '@app/components/ListItem'
import { withSuspenseMemo } from '@app/components/withSuspense'
import { useQueryArtists } from '@app/hooks/query'
import { Artist } from '@app/models/library'
import { ArtistFilterType } from '@app/models/settings'
import { useStore, useStoreDeep } from '@app/state/store'
import React, { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { StyleSheet, View } from 'react-native'
const ArtistRenderItem: React.FC<{ item: Artist }> = ({ item }) => (
<ListItem item={item} showArt={true} showStar={false} listStyle="big" style={styles.listItem} />
)
const filterValues: ArtistFilterType[] = [
'alphabeticalByName', //
'starred',
'random',
]
const ArtistFilterButton = withSuspenseMemo(() => {
const { t } = useTranslation()
const filterType = useStoreDeep(store => store.settings.screens.library.artistsFilter.type)
const setFilterType = useStore(store => store.setLibraryArtistFilterType)
return (
<FilterButton
data={filterValues.map(value => ({ value, text: t(`resources.artist.lists.${value}`) }))}
value={filterType}
onSelect={selection => setFilterType(selection as ArtistFilterType)}
title={t('resources.artist.lists.sort')}
/>
)
})
const ArtistsList = () => {
const filterType = useStore(store => store.settings.screens.library.artistsFilter.type)
const { isLoading, data, refetch } = useQueryArtists()
const [sortedList, setSortedList] = useState<Artist[]>([])
useEffect(() => {
if (!data) {
setSortedList([])
return
}
const list = Object.values(data.byId)
switch (filterType) {
case 'random':
setSortedList([...list].sort(() => Math.random() - 0.5))
break
case 'starred':
setSortedList([...list].filter(a => a.starred))
break
case 'alphabeticalByName':
setSortedList(data.allIds.map(id => data.byId[id]))
break
default:
setSortedList([...list])
break
}
}, [filterType, data])
return (
<View style={styles.container}>
<GradientFlatList
data={sortedList}
renderItem={ArtistRenderItem}
keyExtractor={item => item.id}
onRefresh={refetch}
refreshing={isLoading}
overScrollMode="never"
windowSize={3}
contentMarginTop={6}
/>
<ArtistFilterButton />
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
listItem: {
paddingHorizontal: 10,
},
})
export default ArtistsList
-36
View File
@@ -1,36 +0,0 @@
import GradientFlatList from '@app/components/GradientFlatList'
import ListItem from '@app/components/ListItem'
import { useQueryPlaylists } from '@app/hooks/query'
import { Playlist } from '@app/models/library'
import { mapById } from '@app/util/state'
import React from 'react'
import { StyleSheet } from 'react-native'
const PlaylistRenderItem: React.FC<{ item: Playlist }> = ({ item }) => (
<ListItem item={item} showArt={true} showStar={false} listStyle="big" style={styles.listItem} />
)
const PlaylistsList = () => {
const { isLoading, data, refetch } = useQueryPlaylists()
return (
<GradientFlatList
data={data ? mapById(data?.byId, data?.allIds) : []}
renderItem={PlaylistRenderItem}
keyExtractor={item => item.id}
onRefresh={refetch}
refreshing={isLoading}
overScrollMode="never"
windowSize={5}
contentMarginTop={6}
/>
)
}
const styles = StyleSheet.create({
listItem: {
paddingHorizontal: 10,
},
})
export default PlaylistsList
-56
View File
@@ -1,56 +0,0 @@
import GradientFlatList from '@app/components/GradientFlatList'
import ListItem from '@app/components/ListItem'
import NowPlayingBar from '@app/components/NowPlayingBar'
import { useSkipTo } from '@app/hooks/trackplayer'
import { Song } from '@app/models/library'
import { mapTrackExtToSong } from '@app/models/map'
import { useStoreDeep } from '@app/state/store'
import React from 'react'
import { StyleSheet, View } from 'react-native'
const SongRenderItem: React.FC<{
item: {
song: Song
i: number
onPress: () => void
}
}> = ({ item }) => (
<ListItem
item={item.song}
queueId={item.i}
onPress={item.onPress}
showArt={true}
style={styles.listItem}
subtitle={`${item.song.artist} • ${item.song.album}`}
/>
)
const NowPlayingQueue = React.memo<{}>(() => {
const queue = useStoreDeep(store => store.queue)
const skipTo = useSkipTo()
return (
<View style={styles.container}>
<GradientFlatList
data={queue.map(mapTrackExtToSong).map((song, i) => ({ song, i, onPress: () => skipTo(i) }))}
renderItem={SongRenderItem}
keyExtractor={(item, i) => i.toString()}
overScrollMode="never"
windowSize={7}
contentMarginTop={10}
/>
<NowPlayingBar />
</View>
)
})
const styles = StyleSheet.create({
container: {
flex: 1,
},
listItem: {
paddingHorizontal: 20,
},
})
export default NowPlayingQueue
-422
View File
@@ -1,422 +0,0 @@
import CoverArt from '@app/components/CoverArt'
import HeaderBar from '@app/components/HeaderBar'
import GradientImageBackground from '@app/components/GradientImageBackground'
import PressableOpacity from '@app/components/PressableOpacity'
import { PressableStar } from '@app/components/Star'
import { withSuspenseMemo } from '@app/components/withSuspense'
import { useNext, usePause, usePlay, usePrevious, useSeekTo } from '@app/hooks/trackplayer'
import { mapTrackExtToSong } from '@app/models/map'
import { TrackExt } from '@app/models/trackplayer'
import { useStore, useStoreDeep } from '@app/state/store'
import colors from '@app/styles/colors'
import font from '@app/styles/font'
import formatDuration from '@app/util/formatDuration'
import Slider from '@react-native-community/slider'
import { useNavigation } from '@react-navigation/native'
import React, { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ActivityIndicator, StyleSheet, Text, TextStyle, View } from 'react-native'
import { NativeStackScreenProps } from 'react-native-screens/native-stack'
import { RepeatMode, 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'
const NowPlayingHeader = withSuspenseMemo<{
track?: TrackExt
}>(({ track }) => {
const queueName = useStore(store => store.queueName)
const queueContextType = useStore(store => store.queueContextType)
const { t } = useTranslation()
console.log(t('resources.album.name', { count: 1 }))
if (!track) {
return <></>
}
let contextName: string
if (queueContextType === 'album') {
contextName = t('resources.album.name', { count: 1 })
} else if (queueContextType === 'artist') {
contextName = t('resources.song.lists.artistTopSongs')
} else if (queueContextType === 'playlist') {
contextName = t('resources.playlist.name', { count: 1 })
} else if (queueContextType === 'song') {
contextName = t('search.nowPlayingContext')
}
return (
<HeaderBar
headerStyle={headerStyles.bar}
contextItem={mapTrackExtToSong(track)}
HeaderCenter={() => (
<View style={headerStyles.center}>
{contextName !== undefined && (
<Text numberOfLines={1} style={headerStyles.queueType}>
{contextName}
</Text>
)}
<Text numberOfLines={1} style={headerStyles.queueName}>
{queueName || 'Nothing playing...'}
</Text>
</View>
)}
/>
)
})
const headerStyles = StyleSheet.create({
bar: {
backgroundColor: 'transparent',
},
center: {
flex: 1,
justifyContent: 'center',
},
queueType: {
fontFamily: font.regular,
fontSize: 14,
color: colors.text.primary,
textAlign: 'center',
},
queueName: {
fontFamily: font.bold,
fontSize: 16,
color: colors.text.primary,
textAlign: 'center',
},
})
const SongCoverArt = () => {
const albumId = useStore(store => store.currentTrack?.albumId)
return (
<View style={coverArtStyles.container}>
<CoverArt type="album" size="original" albumId={albumId} style={coverArtStyles.image} />
</View>
)
}
const coverArtStyles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
paddingBottom: 10,
paddingHorizontal: 10,
},
image: {
height: '100%',
width: '100%',
},
})
const SongInfo = () => {
const id = useStore(store => store.currentTrack?.id)
const artist = useStore(store => store.currentTrack?.artist)
const title = useStore(store => store.currentTrack?.title)
return (
<View style={infoStyles.container}>
<View style={infoStyles.details}>
<Text numberOfLines={1} style={infoStyles.title}>
{title}
</Text>
<Text numberOfLines={1} style={infoStyles.artist}>
{artist}
</Text>
</View>
<View style={infoStyles.controls}>
<PressableStar id={id || '-1'} type={'song'} size={32} />
</View>
</View>
)
}
const infoStyles = StyleSheet.create({
container: {
width: '100%',
flexDirection: 'row',
paddingHorizontal: 10,
},
details: {
flex: 1,
marginRight: 20,
},
controls: {
justifyContent: 'center',
},
title: {
minHeight: 30,
fontFamily: font.bold,
fontSize: 22,
color: colors.text.primary,
},
artist: {
minHeight: 21,
fontFamily: font.regular,
fontSize: 16,
color: colors.text.secondary,
},
})
const SeekBar = () => {
const position = useStore(store => store.progress.position)
const duration = useStore(store => store.progress.duration)
const seekTo = useSeekTo()
const [value, setValue] = useState(0)
const [sliding, setSliding] = useState(false)
useEffect(() => {
if (sliding) {
return
}
setValue(position)
}, [position, sliding])
const onSlidingStart = useCallback(() => {
setSliding(true)
}, [])
const onSlidingComplete = useCallback(
async (val: number) => {
await seekTo(val)
setSliding(false)
},
[seekTo],
)
return (
<View style={seekStyles.container}>
<View style={seekStyles.barContainer}>
<Slider
style={seekStyles.slider}
minimumTrackTintColor="white"
maximumTrackTintColor={colors.text.secondary}
thumbTintColor="white"
maximumValue={duration}
value={value}
onSlidingStart={onSlidingStart}
onSlidingComplete={onSlidingComplete}
/>
</View>
<View style={seekStyles.textContainer}>
<Text style={seekStyles.text}>{formatDuration(value)}</Text>
<Text style={seekStyles.text}>{formatDuration(duration)}</Text>
</View>
</View>
)
}
const seekStyles = StyleSheet.create({
container: {
width: '100%',
marginTop: 16,
},
barContainer: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 0,
},
bars: {
backgroundColor: colors.text.primary,
height: 4,
},
slider: {
flex: 1,
height: 40,
},
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',
paddingHorizontal: 10,
},
text: {
fontFamily: font.regular,
fontSize: 15,
color: colors.text.primary,
},
})
const PlayerControls = () => {
const state = useStore(store => store.playerState)
const play = usePlay()
const pause = usePause()
const next = useNext()
const previous = usePrevious()
const shuffled = useStore(store => !!store.shuffleOrder)
const toggleShuffle = useStore(store => store.toggleShuffle)
const repeatMode = useStore(store => store.repeatMode)
const toggleRepeat = useStore(store => store.toggleRepeatMode)
const navigation = useNavigation()
let playPauseIcon: string
let playPauseAction: undefined | (() => void)
let disabled: boolean
switch (state) {
case State.Playing:
disabled = false
playPauseIcon = 'pause-circle'
playPauseAction = pause
break
case State.Buffering:
disabled = false
playPauseIcon = 'circle'
playPauseAction = pause
break
default:
disabled = false
playPauseIcon = 'play-circle'
playPauseAction = play
break
}
const repeatExtOpacity: TextStyle = {
opacity: repeatMode === RepeatMode.Track ? 1 : 0,
}
return (
<View style={controlsStyles.container}>
<View style={controlsStyles.top}>
<View style={controlsStyles.center}>
<PressableOpacity onPress={() => toggleRepeat()} disabled={disabled} hitSlop={16}>
<Icon name="repeat" size={26} color={repeatMode === RepeatMode.Off ? 'white' : colors.accent} />
<Text style={[controlsStyles.repeatExt, repeatExtOpacity]}>1</Text>
</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" />
{state === State.Buffering && (
<ActivityIndicator
style={controlsStyles.buffering}
color={colors.gradient.low}
size="large"
animating={true}
/>
)}
</PressableOpacity>
<PressableOpacity onPress={next} disabled={disabled}>
<IconFA5 name="step-forward" size={36} color="white" />
</PressableOpacity>
</View>
<View style={controlsStyles.center}>
<PressableOpacity onPress={() => toggleShuffle()} disabled={disabled} hitSlop={16}>
<Icon name="shuffle" size={26} color={shuffled ? colors.accent : 'white'} />
</PressableOpacity>
</View>
</View>
<View style={controlsStyles.bottom}>
{/* <PressableOpacity onPress={undefined} disabled={true} hitSlop={16}>
<IconMatCom name="cast-audio" size={20} color="white" />
</PressableOpacity> */}
<PressableOpacity onPress={() => navigation.navigate('queue')} disabled={disabled} hitSlop={16}>
<IconMatCom name="playlist-play" size={24} color="white" />
</PressableOpacity>
</View>
</View>
)
}
const controlsStyles = StyleSheet.create({
container: {
width: '100%',
paddingHorizontal: 10,
},
top: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingBottom: 8,
},
bottom: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'flex-end',
paddingTop: 10,
paddingBottom: 40,
},
play: {
marginHorizontal: 30,
},
center: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
},
repeatExt: {
color: colors.accent,
fontFamily: font.bold,
fontSize: 14,
position: 'absolute',
top: 26,
},
buffering: {
position: 'absolute',
},
})
type RootStackParamList = {
top: undefined
main: undefined
}
type NowPlayingProps = NativeStackScreenProps<RootStackParamList, 'main'>
const NowPlayingView: React.FC<NowPlayingProps> = ({ navigation }) => {
const track = useStoreDeep(store => store.currentTrack)
useEffect(() => {
if (!track) {
navigation.navigate('top')
}
})
const imagePath = typeof track?.artwork === 'string' ? track?.artwork.replace('file://', '') : undefined
return (
<View style={styles.container}>
<GradientImageBackground imagePath={imagePath} height={'100%'} />
<NowPlayingHeader track={track} />
<View style={styles.content}>
<SongCoverArt />
<SongInfo />
<SeekBar />
<PlayerControls />
</View>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
content: {
flex: 1,
paddingHorizontal: 20,
},
})
export default NowPlayingView
-223
View File
@@ -1,223 +0,0 @@
import Button from '@app/components/Button'
import GradientScrollView from '@app/components/GradientScrollView'
import Header from '@app/components/Header'
import ListItem from '@app/components/ListItem'
import NothingHere from '@app/components/NothingHere'
import TextInput from '@app/components/TextInput'
import { withSuspense, withSuspenseMemo } from '@app/components/withSuspense'
import { useQuerySearchResults } from '@app/hooks/query'
import { useSetQueue } from '@app/hooks/trackplayer'
import { Album, Artist, SearchResults, Song } from '@app/models/library'
import colors from '@app/styles/colors'
import font from '@app/styles/font'
import { useFocusEffect, useNavigation } from '@react-navigation/native'
import equal from 'fast-deep-equal/es6/react'
import _ from 'lodash'
import React, { useCallback, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
ActivityIndicator,
InteractionManager,
ScrollView,
StyleSheet,
TextInput as ReactTextInput,
View,
} from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
const SongItem = React.memo<{ item: Song }>(({ item }) => {
const { setQueue, contextId } = useSetQueue('song', [item])
return (
<ListItem
item={item}
contextId={contextId}
queueId={0}
showArt={true}
showStar={false}
onPress={() => setQueue({ title: item.title, playTrack: 0 })}
/>
)
}, equal)
const ResultsCategory = withSuspenseMemo<{
name: string
query: string
items: (Artist | Album | Song)[]
type: 'artist' | 'album' | 'song'
}>(
({ name, query, type, items }) => {
const navigation = useNavigation()
const { t } = useTranslation()
if (items.length === 0) {
return <></>
}
return (
<>
<Header>{name}</Header>
{items.map(a =>
type === 'song' ? (
<SongItem key={a.id} item={a as Song} />
) : (
<ListItem key={a.id} item={a} showArt={true} showStar={false} />
),
)}
{items.length === 5 && (
<Button
title={t('search.moreResults')}
buttonStyle="hollow"
style={styles.more}
onPress={() => navigation.navigate('results', { query, type: items[0].itemType })}
/>
)}
</>
)
},
null,
equal,
)
const Results = withSuspenseMemo<{
results: SearchResults
query: string
}>(
({ results, query }) => {
const { t } = useTranslation()
return (
<>
<ResultsCategory
name={t('resources.artist.name', { count: 2 })}
query={query}
type={'artist'}
items={results.artists}
/>
<ResultsCategory
name={t('resources.album.name', { count: 2 })}
query={query}
type={'album'}
items={results.albums}
/>
<ResultsCategory
name={t('resources.song.name', { count: 2 })}
query={query}
type={'song'}
items={results.songs}
/>
</>
)
},
null,
equal,
)
const Search = withSuspense(() => {
const [query, setQuery] = useState('')
const { data, isLoading } = useQuerySearchResults({ query, albumCount: 5, artistCount: 5, songCount: 5 })
const { t } = useTranslation()
const [text, setText] = useState('')
const searchBarRef = useRef<ReactTextInput>(null)
const scrollRef = useRef<ScrollView>(null)
const paddingTop = useSafeAreaInsets().top
useFocusEffect(
useCallback(() => {
const task = InteractionManager.runAfterInteractions(() => {
setTimeout(() => {
if (text) {
return
}
setText('')
setQuery('')
searchBarRef.current?.focus()
scrollRef.current?.scrollTo({ y: 0, animated: true })
}, 50)
})
return () => task.cancel()
}, [text]),
)
const debouncedSetQuery = useMemo(
() =>
_.debounce((value: string) => {
setQuery(value)
}, 400),
[],
)
const onChangeText = useCallback(
(value: string) => {
setText(value)
debouncedSetQuery(value)
},
[setText, debouncedSetQuery],
)
const resultsCount =
(data ? data.pages.reduce((acc, val) => (acc += val.artists.length), 0) : 0) +
(data ? data.pages.reduce((acc, val) => (acc += val.albums.length), 0) : 0) +
(data ? data.pages.reduce((acc, val) => (acc += val.songs.length), 0) : 0)
return (
<GradientScrollView ref={scrollRef} style={styles.scroll} contentContainerStyle={{ paddingTop }}>
<View style={styles.content}>
<View style={styles.inputBar}>
<TextInput
ref={searchBarRef}
style={styles.textInput}
placeholder={t('search.inputPlaceholder')}
value={text}
onChangeText={onChangeText}
/>
<ActivityIndicator animating={isLoading} size="small" color={colors.text.secondary} style={styles.activity} />
</View>
{data !== undefined && resultsCount > 0 ? (
<Results results={data.pages[0]} query={text} />
) : (
<NothingHere style={styles.noResults} />
)}
</View>
</GradientScrollView>
)
})
const styles = StyleSheet.create({
scroll: {
flex: 1,
},
content: {
paddingHorizontal: 20,
paddingBottom: 20,
alignItems: 'stretch',
},
inputBar: {
justifyContent: 'center',
},
activity: {
position: 'absolute',
right: 16,
bottom: 15,
},
noResults: {
width: '100%',
},
itemText: {
color: colors.text.primary,
fontFamily: font.regular,
fontSize: 14,
},
more: {
marginTop: 5,
marginBottom: 10,
},
textInput: {
marginTop: 20,
paddingHorizontal: 12,
paddingRight: 46,
},
})
export default Search
-115
View File
@@ -1,115 +0,0 @@
import GradientFlatList from '@app/components/GradientFlatList'
import ListItem from '@app/components/ListItem'
import { withSuspense } from '@app/components/withSuspense'
import { useQuerySearchResults } from '@app/hooks/query'
import { useSetQueue } from '@app/hooks/trackplayer'
import { Album, Artist, Song } from '@app/models/library'
import { Search3Params } from '@app/subsonic/params'
import { useNavigation } from '@react-navigation/native'
import React, { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { StyleSheet } from 'react-native'
type SearchListItemType = Album | Song | Artist
const SongResultsListItem: React.FC<{ item: Song }> = ({ item }) => {
const { setQueue, contextId } = useSetQueue('song', [item])
return (
<ListItem
item={item}
contextId={contextId}
queueId={0}
showArt={true}
showStar={false}
listStyle="small"
onPress={() => setQueue({ title: item.title, playTrack: 0 })}
style={styles.listItem}
/>
)
}
const OtherResultsListItem: React.FC<{ item: SearchListItemType }> = ({ item }) => {
return (
<ListItem
item={item}
contextId={item.id}
queueId={0}
showArt={true}
showStar={false}
listStyle="small"
style={styles.listItem}
/>
)
}
const ResultsListItem: React.FC<{ item: SearchListItemType }> = ({ item }) => {
if (item.itemType === 'song') {
return <SongResultsListItem item={item} />
} else {
return <OtherResultsListItem item={item} />
}
}
const SearchResultsRenderItem: React.FC<{ item: SearchListItemType }> = ({ item }) => <ResultsListItem item={item} />
const SearchResultsView = withSuspense<{
query: string
type: 'album' | 'artist' | 'song'
}>(({ query, type }) => {
const navigation = useNavigation()
const { t } = useTranslation()
const size = 100
const params: Search3Params = { query }
if (type === 'album') {
params.albumCount = size
} else if (type === 'artist') {
params.artistCount = size
} else {
params.songCount = size
}
const { data, isLoading, refetch, fetchNextPage } = useQuerySearchResults(params)
const items: (Artist | Album | Song)[] = []
if (type === 'album') {
data && items.push(...data.pages.flatMap(p => p.albums))
} else if (type === 'artist') {
data && items.push(...data.pages.flatMap(p => p.artists))
} else {
data && items.push(...data.pages.flatMap(p => p.songs))
}
useEffect(() => {
navigation.setOptions({
title: t('search.headerTitle', { query }),
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
<GradientFlatList
data={items}
renderItem={SearchResultsRenderItem}
keyExtractor={(item, i) => i.toString()}
onRefresh={refetch}
refreshing={isLoading}
overScrollMode="never"
onEndReached={() => fetchNextPage}
removeClippedSubviews={true}
onEndReachedThreshold={2}
contentMarginTop={6}
windowSize={5}
/>
)
})
const styles = StyleSheet.create({
listItem: {
paddingHorizontal: 10,
},
})
export default SearchResultsView
-284
View File
@@ -1,284 +0,0 @@
import Button from '@app/components/Button'
import GradientScrollView from '@app/components/GradientScrollView'
import SettingsSwitch from '@app/components/SettingsSwitch'
import { withSuspense } from '@app/components/withSuspense'
import { Server } from '@app/models/settings'
import { useStore, useStoreDeep } from '@app/state/store'
import colors from '@app/styles/colors'
import font from '@app/styles/font'
import toast from '@app/util/toast'
import { useNavigation } from '@react-navigation/native'
import md5 from 'md5'
import React, { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { StyleSheet, Text, TextInput, View, ViewStyle } from 'react-native'
import uuid from 'react-native-uuid'
const PASSWORD_PLACEHOLDER = 'PASSWORD_PLACEHOLDER'
const ServerView = withSuspense<{
id?: string
title?: string
}>(({ id, title }) => {
const { t } = useTranslation()
const navigation = useNavigation()
const activeServerId = useStore(store => store.settings.activeServerId)
const servers = useStoreDeep(store => store.settings.servers)
const addServer = useStore(store => store.addServer)
const updateServer = useStore(store => store.updateServer)
const removeServer = useStore(store => store.removeServer)
const server = id ? servers[id] : undefined
const pingServer = useStore(store => store.pingServer)
const [address, setAddress] = useState(server?.address || '')
const [username, setUsername] = useState(server?.username || '')
const [usePlainPassword, setUsePlainPassword] = useState(server?.usePlainPassword ?? false)
const [password, setPassword] = useState(
server?.usePlainPassword ? server.plainPassword || '' : server?.token ? PASSWORD_PLACEHOLDER : '',
)
const [testing, setTesting] = useState(false)
useEffect(() => {
navigation.setOptions({ title })
}, [navigation, title])
const validate = useCallback(() => {
return !!address && !!username && !!password
}, [address, username, password])
const canRemove = useCallback(() => {
return id && Object.keys(servers).length > 1 && activeServerId !== id
}, [id, servers, activeServerId])
const exit = useCallback(() => {
if (navigation.canGoBack()) {
navigation.goBack()
} else {
navigation.navigate('main')
}
}, [navigation])
const createServer = useCallback<() => Server>(() => {
if (usePlainPassword) {
return {
id: server?.id || '',
usePlainPassword,
plainPassword: password,
address,
username,
}
}
let token: string
let salt: string
if (server && !server.usePlainPassword && password === PASSWORD_PLACEHOLDER) {
salt = server.salt
token = server.token
} else {
salt = uuid.v4() as string
token = md5(password + salt)
}
return {
id: server?.id || '',
address,
username,
usePlainPassword,
salt,
token,
}
}, [usePlainPassword, server, address, username, password])
const save = useCallback(() => {
if (!validate()) {
return
}
const update = createServer()
if (id) {
updateServer(update)
} else {
addServer(update)
}
exit()
}, [addServer, createServer, exit, id, updateServer, validate])
const remove = useCallback(() => {
if (!canRemove()) {
return
}
removeServer(id as string)
exit()
}, [canRemove, exit, id, removeServer])
const togglePlainPassword = useCallback(
(value: boolean) => {
setUsePlainPassword(value)
if (value) {
if (server && server.usePlainPassword) {
setPassword(server.plainPassword)
} else if (server) {
setPassword('')
}
} else {
if (server && !server.usePlainPassword) {
setPassword(PASSWORD_PLACEHOLDER)
}
}
},
[server],
)
const test = useCallback(() => {
setTesting(true)
const potential = createServer()
const ping = async () => {
const res = await pingServer(potential)
toast(
t(`settings.servers.messages.${res ? 'connectionOk' : 'connectionFailed'}`, {
address: potential.address,
interpolation: { escapeValue: false },
}),
)
setTesting(false)
}
ping()
}, [createServer, pingServer, t])
const disableControls = useCallback(() => {
return !validate() || testing
}, [validate, testing])
const formatAddress = useCallback(() => {
let addressFormatted = address.trim()
if (addressFormatted.endsWith('/')) {
addressFormatted = addressFormatted.substr(0, addressFormatted.length - 1)
}
if (addressFormatted.length > 0 && !addressFormatted.includes(':/')) {
addressFormatted = `http://${addressFormatted}`
}
setAddress(addressFormatted)
}, [address])
const deleteStyle: ViewStyle = {
display: canRemove() ? 'flex' : 'none',
}
return (
<GradientScrollView style={styles.scroll}>
<View style={styles.content}>
<Text style={styles.inputTitle}>{t('settings.servers.fields.address')}</Text>
<TextInput
style={styles.input}
placeholderTextColor="grey"
selectionColor={colors.text.secondary}
textContentType="URL"
placeholder="http://demo.navidrome.org"
autoCorrect={false}
autoCapitalize="none"
value={address}
onChangeText={setAddress}
onBlur={formatAddress}
/>
<Text style={styles.inputTitle}>{t('settings.servers.fields.username')}</Text>
<TextInput
style={styles.input}
placeholderTextColor="grey"
selectionColor={colors.text.secondary}
textContentType="username"
autoComplete="username"
importantForAutofill="yes"
autoCapitalize="none"
placeholder="demo"
value={username}
onChangeText={setUsername}
/>
<Text style={styles.inputTitle}>{t('settings.servers.fields.password')}</Text>
<TextInput
style={styles.input}
placeholderTextColor="grey"
selectionColor={colors.text.secondary}
textContentType="password"
autoComplete="password"
autoCapitalize="none"
importantForAutofill="yes"
secureTextEntry={true}
placeholder="demo"
value={password}
onChangeText={setPassword}
/>
<SettingsSwitch
title={t('settings.servers.options.forcePlaintextPassword.title')}
subtitle={
usePlainPassword
? t('settings.servers.options.forcePlaintextPassword.descriptionOn')
: t('settings.servers.options.forcePlaintextPassword.descriptionOff')
}
value={usePlainPassword}
setValue={togglePlainPassword}
/>
<Button
disabled={disableControls()}
style={styles.button}
title={t('settings.servers.actions.testConnection')}
buttonStyle="hollow"
onPress={test}
/>
<Button
disabled={disableControls()}
style={[styles.button, styles.delete, deleteStyle]}
title={t('settings.servers.actions.delete')}
onPress={remove}
/>
<Button
disabled={disableControls()}
style={styles.button}
title={t('settings.servers.actions.save')}
onPress={save}
/>
</View>
</GradientScrollView>
)
})
const styles = StyleSheet.create({
scroll: {
flex: 1,
},
content: {
paddingHorizontal: 20,
},
inputTitle: {
fontFamily: font.semiBold,
fontSize: 16,
color: colors.text.primary,
marginTop: 10,
},
input: {
borderBottomWidth: 1.5,
borderColor: colors.text.primary,
fontFamily: font.regular,
fontSize: 16,
color: colors.text.primary,
marginBottom: 26,
},
button: {
marginTop: 16,
},
delete: {
backgroundColor: 'red',
},
})
export default ServerView
-395
View File
@@ -1,395 +0,0 @@
import Button from '@app/components/Button'
import GradientScrollView from '@app/components/GradientScrollView'
import Header from '@app/components/Header'
import PressableOpacity from '@app/components/PressableOpacity'
import SettingsItem from '@app/components/SettingsItem'
import SettingsSwitch from '@app/components/SettingsSwitch'
import TextInput from '@app/components/TextInput'
import { withSuspenseMemo } from '@app/components/withSuspense'
import { useResetImageCache, useSwitchActiveServer } from '@app/hooks/settings'
import { Server } from '@app/models/settings'
import { useStore, useStoreDeep } from '@app/state/store'
import colors from '@app/styles/colors'
import font from '@app/styles/font'
import { useNavigation } from '@react-navigation/core'
import React, { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { KeyboardTypeOptions, Linking, Modal, Pressable, StyleSheet, Text, View } from 'react-native'
import { ScrollView } from 'react-native-gesture-handler'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import Icon from 'react-native-vector-icons/MaterialCommunityIcons'
import { version } from '../../package.json'
const ServerItem = withSuspenseMemo<{
server: Server
}>(({ server }) => {
const activeServerId = useStore(store => store.settings.activeServerId)
const switchActiveServer = useSwitchActiveServer()
const navigation = useNavigation()
const { t } = useTranslation()
const setActive = useCallback(() => {
switchActiveServer(server.id)
}, [server.id, switchActiveServer])
return (
<SettingsItem
title={server.address}
subtitle={server.username}
onPress={() => navigation.navigate('server', { id: server.id, title: t('settings.servers.actions.edit') })}>
<PressableOpacity style={styles.serverActive} onPress={setActive}>
{activeServerId === server.id ? (
<Icon name="checkbox-marked-circle" size={30} color={colors.accent} />
) : (
<Icon name="checkbox-blank-circle-outline" size={30} color={colors.text.secondary} />
)}
</PressableOpacity>
</SettingsItem>
)
})
const ModalChoice = React.memo<{
text: string
value: any
setValue: (value: any) => void
current: boolean
closeModal?: () => void
}>(({ text, value, setValue, current, closeModal }) => {
const onPress = useCallback(() => {
setValue(value)
if (closeModal) {
closeModal()
}
}, [closeModal, setValue, value])
return (
<PressableOpacity onPress={onPress} style={styles.modalChoice}>
<View style={styles.modalRadio}>
{current ? (
<Icon name="checkbox-marked-circle" size={30} color={colors.accent} />
) : (
<Icon name="checkbox-blank-circle-outline" size={30} color={colors.text.secondary} />
)}
</View>
<Text style={styles.modalChoiceText}>{text}</Text>
</PressableOpacity>
)
})
const BitrateModal = withSuspenseMemo<{
title: string
bitrate: number
setBitrate: (bitrate: number) => void
}>(({ title, bitrate, setBitrate }) => {
const { t } = useTranslation()
const [visible, setVisible] = useState(false)
const toggleModal = useCallback(() => setVisible(!visible), [visible])
const bitrateText = useCallback(
(value: number) =>
value === 0 ? t('settings.network.values.unlimitedKbps') : t('settings.network.values.kbps', { value }),
[t],
)
const BitrateChoice: React.FC<{ value: number }> = useCallback(
({ value }) => {
return (
<ModalChoice
text={bitrateText(value)}
value={value}
setValue={setBitrate}
closeModal={toggleModal}
current={bitrate === value}
/>
)
},
[bitrate, toggleModal, setBitrate, bitrateText],
)
return (
<>
<SettingsItem title={title} subtitle={bitrateText(bitrate)} onPress={toggleModal} />
<Modal animationType="fade" transparent={true} visible={visible} onRequestClose={toggleModal}>
<Pressable style={styles.modalBackdrop} onPress={toggleModal}>
<View style={styles.centeredView}>
<Pressable style={styles.modalView}>
<Header style={styles.modalHeader}>{title}</Header>
<ScrollView style={styles.modalScroll}>
<BitrateChoice value={24} />
<BitrateChoice value={32} />
<BitrateChoice value={48} />
<BitrateChoice value={64} />
<BitrateChoice value={96} />
<BitrateChoice value={128} />
<BitrateChoice value={160} />
<BitrateChoice value={192} />
<BitrateChoice value={256} />
<BitrateChoice value={320} />
<BitrateChoice value={0} />
</ScrollView>
</Pressable>
</View>
</Pressable>
</Modal>
</>
)
})
const SettingsTextModal = React.memo<{
title: string
value: string
setValue: (text: string) => void
subtitle: (value: string) => string
keyboardType?: KeyboardTypeOptions
}>(({ title, value, setValue, subtitle, keyboardType }) => {
const [visible, setVisible] = useState(false)
const [inputText, setInputText] = useState(value)
const toggleModal = useCallback(() => setVisible(!visible), [visible])
const submit = useCallback(() => {
setValue(inputText)
toggleModal()
}, [inputText, setValue, toggleModal])
return (
<>
<SettingsItem title={title} subtitle={subtitle(value)} onPress={toggleModal} />
<Modal animationType="fade" transparent={true} visible={visible} onRequestClose={toggleModal}>
<Pressable style={styles.modalBackdrop} onPress={toggleModal}>
<View style={styles.centeredView}>
<Pressable style={styles.modalView}>
<Header style={styles.modalHeader}>{title}</Header>
<View style={styles.modalTextInputLine}>
<TextInput
style={styles.modalTextInput}
value={inputText}
onChangeText={setInputText}
onSubmitEditing={submit}
keyboardType={keyboardType}
/>
<PressableOpacity style={styles.modalTextSubmit} onPress={submit} hitSlop={10}>
<Icon name="content-save-edit" color="white" size={32} />
</PressableOpacity>
</View>
</Pressable>
</View>
</Pressable>
</Modal>
</>
)
})
const SettingsContent = withSuspenseMemo(() => {
const { t } = useTranslation()
const servers = useStoreDeep(store => store.settings.servers)
const scrobble = useStore(store => store.settings.scrobble)
const setScrobble = useStore(store => store.setScrobble)
const maxBitrateWifi = useStore(store => store.settings.maxBitrateWifi)
const setMaxBitrateWifi = useStore(store => store.setMaxBitrateWifi)
const maxBitrateMobile = useStore(store => store.settings.maxBitrateMobile)
const setMaxBitrateMobile = useStore(store => store.setMaxBitrateMobile)
const minBuffer = useStore(store => store.settings.minBuffer)
const setMinBuffer = useStore(store => store.setMinBuffer)
const maxBuffer = useStore(store => store.settings.maxBuffer)
const setMaxBuffer = useStore(store => store.setMaxBuffer)
const [clearing, setClearing] = useState(false)
const resetImageCache = useResetImageCache()
const navigation = useNavigation()
const clear = useCallback(async () => {
setClearing(true)
await resetImageCache()
setClearing(false)
}, [resetImageCache])
const setMinBufferText = useCallback((text: string) => setMinBuffer(parseFloat(text)), [setMinBuffer])
const setMaxBufferText = useCallback((text: string) => setMaxBuffer(parseFloat(text)), [setMaxBuffer])
const secondsText = useCallback((value: string) => t('settings.network.values.seconds', { value }), [t])
return (
<View style={styles.content}>
<Header>{t('settings.servers.name')}</Header>
{Object.values(servers).map(s => (
<ServerItem key={s.id} server={s} />
))}
<Button
style={styles.button}
title={t('settings.servers.actions.add')}
onPress={() => navigation.navigate('server', { title: t('settings.servers.actions.add') })}
buttonStyle="hollow"
/>
<Header style={styles.header}>{t('settings.network.name')}</Header>
<BitrateModal
title={t('settings.network.options.maxBitrateWifi.title')}
bitrate={maxBitrateWifi}
setBitrate={setMaxBitrateWifi}
/>
<BitrateModal
title={t('settings.network.options.maxBitrateMobile.title')}
bitrate={maxBitrateMobile}
setBitrate={setMaxBitrateMobile}
/>
<SettingsTextModal
title={t('settings.network.options.minBuffer.title')}
value={minBuffer.toString()}
setValue={setMinBufferText}
subtitle={secondsText}
keyboardType="numeric"
/>
<SettingsTextModal
title={t('settings.network.options.maxBuffer.title')}
value={maxBuffer.toString()}
setValue={setMaxBufferText}
subtitle={secondsText}
keyboardType="numeric"
/>
<Header style={styles.header}>{t('settings.music.name')}</Header>
<SettingsSwitch
title={t('settings.music.options.scrobble.title')}
subtitle={
scrobble
? t('settings.music.options.scrobble.descriptionOn')
: t('settings.music.options.scrobble.descriptionOff')
}
value={scrobble}
setValue={setScrobble}
/>
<Header style={styles.header}>{t('settings.reset.name')}</Header>
<Button
disabled={clearing}
style={styles.button}
title={t('settings.reset.actions.clearImageCache')}
onPress={clear}
buttonStyle="hollow"
/>
<Header style={styles.header}>{t('settings.about.name')}</Header>
<Text style={styles.text}>
<Text style={styles.bold}>Subtracks</Text> {t('settings.about.version', { version })}
</Text>
<Button
disabled={clearing}
style={styles.button}
title={t('settings.about.actions.projectHomepage')}
onPress={() => Linking.openURL('https://github.com/austinried/subtracks')}
buttonStyle="hollow"
/>
<Button
disabled={clearing}
style={styles.button}
title={t('settings.about.actions.licenses')}
onPress={() =>
navigation.navigate('web', {
uri: 'file:///android_asset/licenses.html',
title: t('settings.about.actions.licenses'),
})
}
buttonStyle="hollow"
/>
</View>
)
})
const Settings = () => {
const paddingTop = useSafeAreaInsets().top
return (
<GradientScrollView style={styles.scroll} contentContainerStyle={{ paddingTop }}>
<SettingsContent />
</GradientScrollView>
)
}
const styles = StyleSheet.create({
scroll: {
flex: 1,
},
content: {
paddingHorizontal: 20,
paddingBottom: 40,
},
serverActive: {
paddingLeft: 12,
},
header: {
marginTop: 26,
},
button: {
marginTop: 16,
},
licenseButton: {
marginHorizontal: 10,
flex: 1,
},
licenses: {
flexDirection: 'row',
justifyContent: 'space-between',
},
modalBackdrop: {
flex: 1,
},
centeredView: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
modalView: {
backgroundColor: colors.gradient.high,
elevation: 5,
},
modalChoice: {
paddingHorizontal: 20,
paddingVertical: 10,
paddingRight: 15,
flexDirection: 'row',
flex: 1,
},
modalChoiceText: {
fontFamily: font.regular,
fontSize: 18,
color: colors.text.primary,
textAlign: 'left',
flex: 1,
},
modalRadio: {
height: 30,
width: 30,
marginRight: 20,
},
modalHeader: {
marginVertical: 20,
marginHorizontal: 30,
},
modalScroll: {
maxHeight: 475,
},
modalTextInputLine: {
flexDirection: 'row',
margin: 20,
},
modalTextInput: {
flex: 1,
paddingLeft: 12,
},
modalTextSubmit: {
marginLeft: 15,
},
text: {
color: 'white',
fontFamily: font.regular,
fontSize: 16,
},
bold: {
fontFamily: font.bold,
},
})
export default Settings
-236
View File
@@ -1,236 +0,0 @@
import CoverArt from '@app/components/CoverArt'
import GradientBackground from '@app/components/GradientBackground'
import HeaderBar from '@app/components/HeaderBar'
import GradientImageFlatList from '@app/components/GradientImageFlatList'
import ListItem from '@app/components/ListItem'
import ListPlayerControls from '@app/components/ListPlayerControls'
import NothingHere from '@app/components/NothingHere'
import { useQueryAlbum, useQueryCoverArtPath, useQueryPlaylist } from '@app/hooks/query'
import { useSetQueue } from '@app/hooks/trackplayer'
import { Album, Playlist, Song } from '@app/models/library'
import colors from '@app/styles/colors'
import font from '@app/styles/font'
import equal from 'fast-deep-equal/es6/react'
import React, { useState } from 'react'
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native'
type SongListType = 'album' | 'playlist'
const SongListDetailsFallback = React.memo(() => (
<GradientBackground style={styles.fallback}>
<ActivityIndicator size="large" color={colors.accent} />
</GradientBackground>
))
const SongRenderItem: React.FC<{
item: {
song: Song
contextId?: string
queueId?: number
subtitle?: string
onPress?: () => void
showArt?: boolean
disabled?: boolean
}
}> = ({ item }) => (
<ListItem
item={item.song}
contextId={item.contextId}
queueId={item.queueId}
subtitle={item.subtitle}
onPress={item.onPress}
showArt={item.showArt}
style={styles.listItem}
disabled={item.disabled}
/>
)
const SongListDetails = React.memo<{
title: string
type: SongListType
songList?: Album | Playlist
songs?: Song[]
subtitle?: string
}>(({ title, songList, songs, subtitle, type }) => {
const { data: coverArtPath } = useQueryCoverArtPath(songList?.coverArt, 'thumbnail')
const [headerColor, setHeaderColor] = useState<string | undefined>(undefined)
const _songs = [...(songs || [])]
if (type === 'album') {
if (_songs.some(s => s.track === undefined)) {
_songs.sort((a, b) => a.title.localeCompare(b.title))
} else {
_songs.sort((a, b) => {
const aVal = (a.track as number) + (a.discNumber !== undefined ? a.discNumber * 10000 : 0)
const bVal = (b.track as number) + (b.discNumber !== undefined ? b.discNumber * 10000 : 0)
return aVal - bVal
})
}
}
const { setQueue, contextId } = useSetQueue(type, _songs)
if (!songList) {
return <SongListDetailsFallback />
}
const disabled = _songs.length === 0
const play = (track?: number, shuffle?: boolean) => () =>
setQueue({ title: songList.name, playTrack: track, shuffle })
return (
<View style={styles.container}>
<HeaderBar
headerStyle={{ backgroundColor: headerColor }}
title={title}
contextItem={songList.itemType === 'album' ? songList : undefined}
/>
<GradientImageFlatList
data={_songs.map((s, i) => ({
song: s,
contextId,
queueId: i,
subtitle: s.artist,
onPress: play(i),
showArt: songList.itemType === 'playlist',
disabled: disabled,
}))}
renderItem={SongRenderItem}
keyExtractor={(item, i) => i.toString()}
backgroundProps={{
imagePath: coverArtPath,
style: styles.container,
onGetColor: setHeaderColor,
}}
overScrollMode="never"
windowSize={7}
contentMarginTop={26}
ListEmptyComponent={
songs ? (
<NothingHere style={styles.nothing} />
) : (
<ActivityIndicator size="large" color={colors.accent} style={styles.listLoading} />
)
}
ListHeaderComponent={
<View style={styles.content}>
<CoverArt type="cover" size="original" coverArt={songList.coverArt} style={styles.cover} />
<Text style={styles.title}>{songList.name}</Text>
{subtitle ? <Text style={styles.subtitle}>{subtitle}</Text> : <></>}
<ListPlayerControls
style={styles.controls}
songs={_songs}
listType={type}
play={play(undefined, false)}
shuffle={play(undefined, true)}
disabled={disabled}
/>
</View>
}
/>
</View>
)
}, equal)
const PlaylistView = React.memo<{
id: string
title: string
playlist?: Playlist
}>(({ id, title, playlist }) => {
const query = useQueryPlaylist(id, playlist)
return (
<SongListDetails
title={title}
songList={query.data?.playlist}
songs={query.data?.songs}
subtitle={query.data?.playlist?.comment}
type="playlist"
/>
)
}, equal)
const AlbumView = React.memo<{
id: string
title: string
album?: Album
}>(({ id, title, album }) => {
const query = useQueryAlbum(id, album)
return (
<SongListDetails
title={title}
songList={query.data?.album}
songs={query.data?.songs}
subtitle={(query.data?.album?.artist || '') + (query.data?.album?.year ? ' • ' + query.data?.album?.year : '')}
type="album"
/>
)
}, equal)
const SongListView = React.memo<{
id: string
title: string
type: SongListType
album?: Album
playlist?: Playlist
}>(({ id, title, type, album, playlist }) => {
return type === 'album' ? (
<AlbumView id={id} title={title} album={album} />
) : (
<PlaylistView id={id} title={title} playlist={playlist} />
)
}, equal)
const styles = StyleSheet.create({
container: {
flex: 1,
},
content: {
alignItems: 'center',
paddingTop: 10,
paddingHorizontal: 20,
},
controls: {
marginTop: 20,
},
title: {
fontSize: 24,
fontFamily: font.bold,
color: colors.text.primary,
marginTop: 20,
textAlign: 'center',
},
subtitle: {
fontFamily: font.regular,
color: colors.text.secondary,
fontSize: 14,
marginTop: 4,
textAlign: 'center',
},
cover: {
height: 240,
width: 240,
},
songs: {
marginTop: 26,
marginBottom: 30,
width: '100%',
},
fallback: {
alignItems: 'center',
paddingTop: 100,
},
listItem: {
paddingHorizontal: 20,
},
nothing: {
width: '100%',
},
listLoading: {
marginTop: 10,
},
})
export default SongListView
-97
View File
@@ -1,97 +0,0 @@
import React, { useEffect, useState } from 'react'
import { Image, View, StyleSheet, Text } from 'react-native'
import { Store, useStore } from '@app/state/store'
import colors from '@app/styles/colors'
import GradientBackground from '@app/components/GradientBackground'
import font from '@app/styles/font'
import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated'
const selectHydrated = (store: Store) => store.hydrated
const SplashPage: React.FC<{}> = ({ children }) => {
const [ready, setReady] = useState(false)
const hydrated = useStore(selectHydrated)
const opacity = useSharedValue(0)
const animatedStyles = useAnimatedStyle(() => {
return {
opacity: opacity.value,
}
})
const minSplashTime = new Promise(resolve => setTimeout(resolve, 1000))
const prepare = async () => {
return
}
const promise = Promise.all([prepare(), minSplashTime])
useEffect(() => {
opacity.value = withTiming(1, {
duration: 200,
})
promise
.then(() => {
setReady(true)
})
.then(() => {
opacity.value = withTiming(0, {
duration: 500,
})
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const splash = (
<Animated.View style={[styles.splashContainer, animatedStyles]} pointerEvents="none">
<GradientBackground style={styles.background} height="100%">
<View style={styles.logoContainer}>
<Image style={styles.image} source={require('@res/casette.png')} fadeDuration={0} />
<Text style={styles.text}>subtracks</Text>
</View>
</GradientBackground>
</Animated.View>
)
return (
<View style={styles.container}>
{ready && hydrated && children}
{splash}
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: 'transparent',
},
splashContainer: {
position: 'absolute',
height: '100%',
width: '100%',
},
background: {
justifyContent: 'center',
alignItems: 'center',
},
logoContainer: {
elevation: 5,
justifyContent: 'center',
alignItems: 'center',
},
image: {
height: 150,
width: 150,
marginBottom: -10,
tintColor: colors.accent,
},
text: {
fontFamily: font.bold,
fontSize: 31,
color: colors.text.primary,
},
})
export default SplashPage
-18
View File
@@ -1,18 +0,0 @@
import { useNavigation } from '@react-navigation/native'
import React, { useEffect } from 'react'
import { WebView } from 'react-native-webview'
const WebViewScreen: React.FC<{
uri: string
title?: string
}> = ({ uri, title }) => {
const navigation = useNavigation()
useEffect(() => {
navigation.setOptions({ title })
}, [navigation, title])
return <WebView source={{ uri }} />
}
export default WebViewScreen
-69
View File
@@ -1,69 +0,0 @@
import { Server } from '@app/models/settings'
import { ById } from '@app/models/state'
import { newCacheBuster } from './settings'
import RNFS from 'react-native-fs'
const migrations: Array<(state: any) => Promise<any>> = [
// 1
async state => {
for (let server of state.settings.servers) {
server.usePlainPassword = false
}
return state
},
// 2
async state => {
state.settings.servers = state.settings.servers.reduce((acc: ById<Server>, server: Server) => {
acc[server.id] = server
return acc
}, {} as ById<Server>)
state.settings.activeServerId = state.settings.activeServer
delete state.settings.activeServer
state.settings.screens.home.listTypes = [...state.settings.screens.home.lists]
delete state.settings.screens.home.lists
state.settings.screens.library.albumsFilter = { ...state.settings.screens.library.albums }
delete state.settings.screens.library.albums
state.settings.screens.library.artistsFilter = { ...state.settings.screens.library.artists }
delete state.settings.screens.library.artists
delete state.settings.estimateContentLength
return state
},
// 3
async state => {
state.settings.cacheBuster = newCacheBuster()
state.settings.servers = Object.values(state.settings.servers as Record<string, Server>).reduce(
(acc, server, i) => {
const newId = i.toString()
if (server.id === state.settings.activeServerId) {
state.settings.activeServerId = newId
}
server.id = newId
acc[newId] = server
return acc
},
{} as Record<string, Server>,
)
try {
await RNFS.unlink(`${RNFS.DocumentDirectoryPath}/servers`)
} catch (err) {
console.error(err)
}
return state
},
]
export default migrations
-238
View File
@@ -1,238 +0,0 @@
import { AlbumFilterSettings, ArtistFilterSettings, ArtistFilterType, Server } from '@app/models/settings'
import { ById } from '@app/models/state'
import { GetStore, SetStore } from '@app/state/store'
import { SubsonicApiClient } from '@app/subsonic/api'
import { GetAlbumList2TypeBase } from '@app/subsonic/params'
import uuid from 'react-native-uuid'
export type SettingsSlice = {
settings: {
servers: ById<Server>
activeServerId?: string
screens: {
home: {
listTypes: string[]
}
library: {
albumsFilter: AlbumFilterSettings
artistsFilter: ArtistFilterSettings
}
}
scrobble: boolean
maxBitrateWifi: number
maxBitrateMobile: number
minBuffer: number
maxBuffer: number
cacheBuster: string
}
client?: SubsonicApiClient
disableMusicTabs: boolean
setDisableMusicTabs: (value: boolean) => void
changeCacheBuster: () => void
setActiveServer: (id: string | undefined, force?: boolean) => Promise<void>
addServer: (server: Server) => void
removeServer: (id: string) => void
updateServer: (server: Server) => void
setScrobble: (scrobble: boolean) => void
setMaxBitrateWifi: (maxBitrateWifi: number) => void
setMaxBitrateMobile: (maxBitrateMobile: number) => void
setMinBuffer: (minBuffer: number) => void
setMaxBuffer: (maxBuffer: number) => void
pingServer: (server?: Server) => Promise<boolean>
setLibraryAlbumFilterType: (type: GetAlbumList2TypeBase) => void
setLibraryArtistFilterType: (type: ArtistFilterType) => void
}
export function newCacheBuster(): string {
return (uuid.v4() as string).split('-')[0]
}
export const createSettingsSlice = (set: SetStore, get: GetStore): SettingsSlice => ({
settings: {
servers: {},
screens: {
home: {
listTypes: ['frequent', 'recent', 'starred', 'random'],
},
library: {
albumsFilter: {
type: 'alphabeticalByArtist',
fromYear: 1,
toYear: 9999,
genre: '',
},
artistsFilter: {
type: 'alphabeticalByName',
},
},
},
scrobble: false,
maxBitrateWifi: 0,
maxBitrateMobile: 192,
minBuffer: 6,
maxBuffer: 60,
cacheBuster: newCacheBuster(),
},
disableMusicTabs: false,
setDisableMusicTabs: value => {
set(store => {
store.disableMusicTabs = value
})
},
changeCacheBuster: () => {
set(store => {
store.settings.cacheBuster = newCacheBuster()
})
},
setActiveServer: async (id, force) => {
const servers = get().settings.servers
const currentActiveServerId = get().settings.activeServerId
const newActiveServer = id ? servers[id] : undefined
if (!newActiveServer) {
set(state => {
state.client = undefined
})
return
}
if (currentActiveServerId === id && !force) {
return
}
set(state => {
state.disableMusicTabs = true
})
set(state => {
state.settings.activeServerId = newActiveServer.id
state.client = new SubsonicApiClient(newActiveServer)
})
set(state => {
state.disableMusicTabs = false
})
},
addServer: server => {
const serverIds = Object.keys(get().settings.servers)
server.id =
serverIds.length === 0 ? '0' : (serverIds.map(i => parseInt(i, 10)).sort((a, b) => b - a)[0] + 1).toString()
set(state => {
state.settings.servers[server.id] = server
})
if (Object.keys(get().settings.servers).length === 1) {
get().setActiveServer(server.id)
}
},
removeServer: id => {
set(state => {
delete state.settings.servers[id]
})
},
updateServer: server => {
set(state => {
state.settings.servers[server.id] = server
})
if (get().settings.activeServerId === server.id) {
get().setActiveServer(server.id, true)
}
},
setScrobble: scrobble => {
set(state => {
state.settings.scrobble = scrobble
})
},
setMaxBitrateWifi: maxBitrateWifi => {
set(state => {
state.settings.maxBitrateWifi = maxBitrateWifi
})
if (get().netState === 'wifi') {
get().rebuildQueue()
}
},
setMaxBitrateMobile: maxBitrateMobile => {
set(state => {
state.settings.maxBitrateMobile = maxBitrateMobile
})
if (get().netState === 'mobile') {
get().rebuildQueue()
}
},
setMinBuffer: minBuffer => {
if (minBuffer === get().settings.minBuffer) {
return
}
set(state => {
state.settings.minBuffer = Math.max(1, Math.min(minBuffer, state.settings.maxBuffer / 2))
})
get().rebuildQueue()
},
setMaxBuffer: maxBuffer => {
if (maxBuffer === get().settings.maxBuffer) {
return
}
set(state => {
state.settings.maxBuffer = Math.min(5 * 60, Math.max(maxBuffer, state.settings.minBuffer * 2))
})
get().rebuildQueue()
},
pingServer: async server => {
let client: SubsonicApiClient
if (server) {
client = new SubsonicApiClient(server)
} else {
const currentClient = get().client
if (!currentClient) {
return false
}
client = currentClient
}
try {
await client.ping()
return true
} catch {
return false
}
},
setLibraryAlbumFilterType: type => {
set(state => {
state.settings.screens.library.albumsFilter.type = type
})
},
setLibraryArtistFilterType: type => {
set(state => {
state.settings.screens.library.artistsFilter.type = type
})
},
})
-95
View File
@@ -1,95 +0,0 @@
import { createSettingsSlice, SettingsSlice } from '@app/state/settings'
import AsyncStorage from '@react-native-async-storage/async-storage'
import equal from 'fast-deep-equal'
import create, { GetState, Mutate, SetState, State, StateCreator, StateSelector, StoreApi } from 'zustand'
import { persist, subscribeWithSelector } from 'zustand/middleware'
import migrations from './migrations'
import { createTrackPlayerSlice, TrackPlayerSlice } from './trackplayer'
import produce, { Draft } from 'immer'
import { WritableDraft } from 'immer/dist/internal'
const DB_VERSION = migrations.length
export type Store = SettingsSlice &
TrackPlayerSlice & {
hydrated: boolean
setHydrated: (hydrated: boolean) => void
}
// taken from zustand test examples:
// https://github.com/pmndrs/zustand/blob/v3.7.1/tests/middlewareTypes.test.tsx#L20
const immer =
<
T extends State,
CustomSetState extends SetState<T>,
CustomGetState extends GetState<T>,
CustomStoreApi extends StoreApi<T>,
>(
config: StateCreator<
T,
(partial: ((draft: Draft<T>) => void) | T, replace?: boolean) => void,
CustomGetState,
CustomStoreApi
>,
): StateCreator<T, CustomSetState, CustomGetState, CustomStoreApi> =>
(set, get, api) =>
config(
(partial, replace) => {
const nextState = typeof partial === 'function' ? produce(partial as (state: Draft<T>) => T) : (partial as T)
return set(nextState, replace)
},
get,
api,
)
export type SetStore = (partial: Store | ((draft: WritableDraft<Store>) => void), replace?: boolean | undefined) => void
export type GetStore = GetState<Store>
// types taken from zustand test examples:
// https://github.com/pmndrs/zustand/blob/v3.7.1/tests/middlewareTypes.test.tsx#L584
export const useStore = create<
Store,
SetState<Store>,
GetState<Store>,
Mutate<StoreApi<Store>, [['zustand/subscribeWithSelector', never], ['zustand/persist', Partial<Store>]]>
>(
subscribeWithSelector(
persist(
immer((set, get) => ({
...createSettingsSlice(set, get),
...createTrackPlayerSlice(set, get),
hydrated: false,
setHydrated: hydrated =>
set(state => {
state.hydrated = hydrated
}),
})),
{
name: '@appStore',
version: DB_VERSION,
getStorage: () => AsyncStorage,
partialize: state => ({ settings: state.settings }),
onRehydrateStorage: _preState => {
return async (postState, _error) => {
await postState?.setActiveServer(postState.settings.activeServerId, true)
postState?.setHydrated(true)
}
},
migrate: async (persistedState, version) => {
if (version > DB_VERSION) {
throw new Error('cannot migrate db on a downgrade, delete all data first')
}
for (let i = version; i < DB_VERSION; i++) {
persistedState = await migrations[i](persistedState)
}
return persistedState
},
},
),
),
)
export const useStoreDeep = <U>(stateSelector: StateSelector<Store, U>) => useStore(stateSelector, equal)
-412
View File
@@ -1,412 +0,0 @@
import { NoClientError } from '@app/models/error'
import { Progress, QueueContextType, TrackExt } from '@app/models/trackplayer'
import PromiseQueue from '@app/util/PromiseQueue'
import produce from 'immer'
import TrackPlayer, { PlayerOptions, RepeatMode, State } from 'react-native-track-player'
import { GetStore, SetStore } from './store'
export type SetQueueOptions = {
title: string
playTrack?: number
shuffle?: boolean
}
export type SetQueueOptionsInternal = SetQueueOptions & {
queue: TrackExt[]
contextId: string
type: QueueContextType
}
export type TrackPlayerSlice = {
queueName?: string
setQueueName: (name?: string) => void
queueContextType?: QueueContextType
setQueueContextType: (queueContextType?: QueueContextType) => void
queueContextId?: string
setQueueContextId: (queueContextId?: string) => void
shuffleOrder?: number[]
toggleShuffle: () => Promise<void>
repeatMode: RepeatMode
toggleRepeatMode: () => Promise<void>
playerState: State
setPlayerState: (playerState: State) => void
duckPaused: boolean
setDuckPaused: (duckPaused: boolean) => void
currentTrack?: TrackExt
currentTrackIdx?: number
setCurrentTrackIdx: (idx?: number) => void
queue: TrackExt[]
setQueue: (options: SetQueueOptionsInternal) => Promise<void>
progress: Progress
setProgress: (progress: Progress) => void
scrobbleTrack: (id: string) => Promise<void>
netState: 'mobile' | 'wifi'
setNetState: (netState: 'mobile' | 'wifi') => Promise<void>
rebuildQueue: (forcePlay?: boolean) => Promise<void>
updateQueue: () => Promise<void>
buildStreamUri: (id: string) => string
resetTrackPlayerState: () => void
getPlayerOptions: () => PlayerOptions
}
export const trackPlayerCommands = new PromiseQueue(1)
export const createTrackPlayerSlice = (set: SetStore, get: GetStore): TrackPlayerSlice => ({
queueName: undefined,
setQueueName: name =>
set(state => {
state.queueName = name
}),
queueContextType: undefined,
setQueueContextType: queueContextType =>
set(state => {
state.queueContextType = queueContextType
}),
queueContextId: undefined,
setQueueContextId: queueContextId =>
set(state => {
state.queueContextId = queueContextId
}),
shuffleOrder: undefined,
toggleShuffle: async () => {
return trackPlayerCommands.enqueue(async () => {
const queue = await getQueue()
const current = await getCurrentTrack()
const queueShuffleOrder = get().shuffleOrder
await TrackPlayer.remove(queue.map((_t, i) => i).filter(i => i !== current))
if (queueShuffleOrder === undefined) {
let { tracks, shuffleOrder } = shuffleTracks(queue, current)
if (tracks.length > 0) {
tracks = tracks.slice(1)
}
await TrackPlayer.add(tracks)
set(state => {
state.shuffleOrder = shuffleOrder
})
} else {
const tracks = unshuffleTracks(queue, queueShuffleOrder)
if (current !== undefined) {
const shuffledCurrent = queueShuffleOrder[current]
const tracks1 = tracks.slice(0, shuffledCurrent)
const tracks2 = tracks.slice(shuffledCurrent + 1)
await TrackPlayer.add(tracks2)
await TrackPlayer.add(tracks1, 0)
} else {
await TrackPlayer.add(tracks)
}
set(state => {
state.shuffleOrder = undefined
})
}
const newQueue = await getQueue()
const newCurrentTrackIdx = await getCurrentTrack()
set(state => {
state.queue = newQueue
})
get().setCurrentTrackIdx(newCurrentTrackIdx)
})
},
repeatMode: RepeatMode.Off,
toggleRepeatMode: async () => {
return trackPlayerCommands.enqueue(async () => {
const repeatMode = await getRepeatMode()
let nextMode = RepeatMode.Off
switch (repeatMode) {
case RepeatMode.Off:
nextMode = RepeatMode.Queue
break
case RepeatMode.Queue:
nextMode = RepeatMode.Track
break
default:
nextMode = RepeatMode.Off
break
}
await TrackPlayer.setRepeatMode(nextMode)
set(state => {
state.repeatMode = nextMode
})
})
},
playerState: State.None,
setPlayerState: playerState =>
set(state => {
state.playerState = playerState
}),
currentTrack: undefined,
currentTrackIdx: undefined,
setCurrentTrackIdx: idx => {
set(
produce<TrackPlayerSlice>(state => {
state.currentTrackIdx = idx
state.currentTrack = idx !== undefined ? state.queue[idx] : undefined
}),
)
},
duckPaused: false,
setDuckPaused: duckPaused =>
set(state => {
state.duckPaused = duckPaused
}),
queue: [],
setQueue: async ({ queue, title, type, contextId, playTrack, shuffle }) => {
return trackPlayerCommands.enqueue(async () => {
const shuffled = shuffle !== undefined ? shuffle : !!get().shuffleOrder
await TrackPlayer.setupPlayer(get().getPlayerOptions())
await TrackPlayer.reset()
if (queue.length === 0) {
return
}
if (shuffled) {
const { tracks, shuffleOrder } = shuffleTracks(queue, playTrack)
set(state => {
state.shuffleOrder = shuffleOrder
})
queue = tracks
playTrack = 0
} else {
set(state => {
state.shuffleOrder = undefined
})
}
playTrack = playTrack || 0
try {
set(state => {
state.queue = queue
state.queueName = title
state.queueContextType = type
state.queueContextId = contextId
})
get().setCurrentTrackIdx(playTrack)
if (playTrack === 0) {
await TrackPlayer.add(queue)
} else {
const tracks1 = queue.slice(0, playTrack)
const tracks2 = queue.slice(playTrack)
await TrackPlayer.add(tracks2)
await TrackPlayer.add(tracks1, 0)
}
await TrackPlayer.play()
} catch {
get().resetTrackPlayerState()
await TrackPlayer.reset()
}
})
},
progress: { position: 0, duration: 0, buffered: 0 },
setProgress: progress =>
set(state => {
state.progress = progress
}),
scrobbleTrack: async id => {
const client = get().client
if (!client) {
return
}
if (!get().settings.scrobble) {
return
}
try {
await client.scrobble({ id })
} catch {}
},
netState: 'mobile',
setNetState: async netState => {
if (netState === get().netState) {
return
}
set(state => {
state.netState = netState
})
get().rebuildQueue()
},
rebuildQueue: async forcePlay => {
return trackPlayerCommands.enqueue(async () => {
const queue = await getQueue()
if (!queue.length) {
return
}
const currentTrack = await getCurrentTrack()
const playerState = await getPlayerState()
const position = (await TrackPlayer.getPosition()) || 0
const repeatMode = await getRepeatMode()
const queueName = get().queueName
const queueContextId = get().queueContextId
const queueContextType = get().queueContextType
await TrackPlayer.reset()
await TrackPlayer.setupPlayer(get().getPlayerOptions())
try {
for (const track of queue) {
track.url = get().buildStreamUri(track.id)
}
} catch {
return
}
set(state => {
state.queue = queue
state.queueName = queueName
state.queueContextType = queueContextType
state.queueContextId = queueContextId
})
get().setCurrentTrackIdx(currentTrack)
await TrackPlayer.add(queue)
if (currentTrack) {
await TrackPlayer.skip(currentTrack)
}
await TrackPlayer.setRepeatMode(repeatMode)
await TrackPlayer.seekTo(position)
if (playerState === State.Playing || forcePlay) {
await TrackPlayer.play()
}
})
},
updateQueue: async () => {
const newQueue = await getQueue()
const currentTrack = await getCurrentTrack()
set(state => {
state.queue = newQueue
if (currentTrack !== undefined) {
state.currentTrack = newQueue[currentTrack]
}
})
},
buildStreamUri: id => {
const client = get().client
if (!client) {
throw new NoClientError()
}
return client.streamUri({
id,
estimateContentLength: true,
maxBitRate: get().netState === 'mobile' ? get().settings.maxBitrateMobile : get().settings.maxBitrateWifi,
})
},
resetTrackPlayerState: () => {
set(state => {
state.queueName = undefined
state.queueContextType = undefined
state.queueContextId = undefined
state.shuffleOrder = undefined
state.repeatMode = RepeatMode.Off
state.playerState = State.None
state.currentTrack = undefined
state.currentTrackIdx = undefined
state.queue = []
state.progress = { position: 0, duration: 0, buffered: 0 }
})
},
getPlayerOptions: () => {
return {
minBuffer: get().settings.minBuffer,
playBuffer: get().settings.minBuffer / 2,
maxBuffer: get().settings.maxBuffer,
}
},
})
export const getQueue = async (): Promise<TrackExt[]> => {
return ((await TrackPlayer.getQueue()) as TrackExt[]) || []
}
export const getCurrentTrack = async (): Promise<number | undefined> => {
const current = await TrackPlayer.getCurrentTrack()
return typeof current === 'number' ? current : undefined
}
export const getPlayerState = async (): Promise<State> => {
return (await TrackPlayer.getState()) || State.None
}
export const getRepeatMode = async (): Promise<RepeatMode> => {
return (await TrackPlayer.getRepeatMode()) || RepeatMode.Off
}
function shuffleTracks(tracks: TrackExt[], firstTrack?: number) {
if (tracks.length === 0) {
return { tracks, shuffleOrder: [] }
}
const trackIndexes = tracks.map((_t, i) => i)
let shuffleOrder: number[] = []
for (let i = trackIndexes.length; i--; i > 0) {
const randi = Math.floor(Math.random() * (i + 1))
shuffleOrder.push(trackIndexes.splice(randi, 1)[0])
}
if (firstTrack !== undefined) {
shuffleOrder.splice(shuffleOrder.indexOf(firstTrack), 1)
shuffleOrder = [firstTrack, ...shuffleOrder]
}
tracks = shuffleOrder.map(i => tracks[i])
return { tracks, shuffleOrder }
}
function unshuffleTracks(tracks: TrackExt[], shuffleOrder: number[]): TrackExt[] {
if (tracks.length === 0 || shuffleOrder.length === 0) {
return tracks
}
return shuffleOrder.map((_v, i) => tracks[shuffleOrder.indexOf(i)])
}
-12
View File
@@ -1,12 +0,0 @@
export default {
text: {
primary: '#ffffff',
secondary: '#999999',
},
gradient: {
high: '#2d2d2d',
low: '#000000',
},
accent: '#b134db',
accentLow: '#511c63',
}
-10
View File
@@ -1,10 +0,0 @@
const header = 56
const tabBar = 54
const bottom = () => tabBar
export default {
header,
tabBar,
bottom,
}
-8
View File
@@ -1,8 +0,0 @@
enum font {
regular = 'Metropolis-Regular',
semiBold = 'Metropolis-SemiBold',
bold = 'Metropolis-Bold',
lightItalic = 'Metropolis-SemiBoldItalic',
}
export default font
-23
View File
@@ -1,23 +0,0 @@
export type OutlineFillIcon = {
outline: number
fill: number
}
export const bottomTabIcons: Record<string, OutlineFillIcon> = {
home: {
outline: require('@res/icons/home.png'),
fill: require('@res/icons/home-fill.png'),
},
library: {
outline: require('@res/icons/library.png'),
fill: require('@res/icons/library-fill.png'),
},
search: {
outline: require('@res/icons/search.png'),
fill: require('@res/icons/search-fill.png'),
},
settings: {
outline: require('@res/icons/settings.png'),
fill: require('@res/icons/settings-fill.png'),
},
}
-239
View File
@@ -1,239 +0,0 @@
import { Server } from '@app/models/settings'
import {
GetAlbumList2Params,
GetAlbumListParams,
GetAlbumParams,
GetArtistInfo2Params,
GetArtistInfoParams,
GetArtistParams,
GetCoverArtParams,
GetIndexesParams,
GetMusicDirectoryParams,
GetPlaylistParams,
GetPlaylistsParams,
GetSongParams,
GetTopSongsParams,
ScrobbleParams,
Search3Params,
StarParams,
StreamParams,
} from '@app/subsonic/params'
import {
GetAlbumList2Response,
GetAlbumListResponse,
GetAlbumResponse,
GetArtistInfo2Response,
GetArtistInfoResponse,
GetArtistResponse,
GetArtistsResponse,
GetIndexesResponse,
GetMusicDirectoryResponse,
GetPlaylistResponse,
GetPlaylistsResponse,
GetSongResponse,
GetTopSongsResponse,
NullResponse,
Search3Response,
} from '@app/subsonic/responses'
import toast from '@app/util/toast'
import userAgent from '@app/util/userAgent'
import { DOMParser } from '@xmldom/xmldom'
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
}
}
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', encodeURIComponent(server.username))
if (server.usePlainPassword) {
this.params.append('p', encodeURIComponent(server.plainPassword))
} else {
this.params.append('t', encodeURIComponent(server.token))
this.params.append('s', encodeURIComponent(server.salt))
}
this.params.append('v', '1.13.0')
this.params.append('c', 'subtracks')
}
private buildUrl(method: string, params?: { [key: string]: any }): string {
let urlParams = this.params.toString()
if (params) {
const methodParams = this.obj2Params(params)
if (methodParams) {
urlParams += '&' + methodParams.toString()
}
}
// *.view was present on all method names in API 1.14.0 and earlier
return `${this.address}/rest/${method}.view?${urlParams}`
}
private async apiGetXml(method: string, params?: { [key: string]: any }): Promise<Document> {
let text: string
try {
const response = await fetch(this.buildUrl(method, params), {
headers: { 'User-Agent': userAgent },
})
text = await response.text()
} catch (err) {
toast(`Network error: ${this.address}`)
throw err
}
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) {
if (obj[key] === undefined || obj[key] === null) {
continue
}
params.append(key, encodeURIComponent(String(obj[key])))
}
return params
}
//
// System
//
async ping(): Promise<NullResponse> {
return new NullResponse(await this.apiGetXml('ping'))
}
//
// Browsing
//
async getArtists(): Promise<GetArtistsResponse> {
return new GetArtistsResponse(await this.apiGetXml('getArtists'))
}
async getIndexes(params?: GetIndexesParams): Promise<GetIndexesResponse> {
return new GetIndexesResponse(await this.apiGetXml('getIndexes', params))
}
async getMusicDirectory(params: GetMusicDirectoryParams): Promise<GetMusicDirectoryResponse> {
return new GetMusicDirectoryResponse(await this.apiGetXml('getMusicDirectory', params))
}
async getAlbum(params: GetAlbumParams): Promise<GetAlbumResponse> {
return new GetAlbumResponse(await this.apiGetXml('getAlbum', params))
}
async getArtistInfo(params: GetArtistInfoParams): Promise<GetArtistInfoResponse> {
return new GetArtistInfoResponse(await this.apiGetXml('getArtistInfo', params))
}
async getArtistInfo2(params: GetArtistInfo2Params): Promise<GetArtistInfo2Response> {
return new GetArtistInfo2Response(await this.apiGetXml('getArtistInfo2', params))
}
async getArtist(params: GetArtistParams): Promise<GetArtistResponse> {
return new GetArtistResponse(await this.apiGetXml('getArtist', params))
}
async getTopSongs(params: GetTopSongsParams): Promise<GetTopSongsResponse> {
return new GetTopSongsResponse(await this.apiGetXml('getTopSongs', params))
}
async getSong(params: GetSongParams): Promise<GetSongResponse> {
return new GetSongResponse(await this.apiGetXml('getSong', params))
}
//
// Album/song lists
//
async getAlbumList(params: GetAlbumListParams): Promise<GetAlbumListResponse> {
return new GetAlbumListResponse(await this.apiGetXml('getAlbumList', params))
}
async getAlbumList2(params: GetAlbumList2Params): Promise<GetAlbumList2Response> {
return new GetAlbumList2Response(await this.apiGetXml('getAlbumList2', params))
}
//
// Playlists
//
async getPlaylists(params?: GetPlaylistsParams): Promise<GetPlaylistsResponse> {
return new GetPlaylistsResponse(await this.apiGetXml('getPlaylists', params))
}
async getPlaylist(params: GetPlaylistParams): Promise<GetPlaylistResponse> {
return new GetPlaylistResponse(await this.apiGetXml('getPlaylist', params))
}
//
// Media retrieval
//
getCoverArtUri(params?: GetCoverArtParams): string {
return this.buildUrl('getCoverArt', params)
}
streamUri(params: StreamParams): string {
return this.buildUrl('stream', params)
}
//
// Media annotation
//
async scrobble(params: ScrobbleParams): Promise<NullResponse> {
return new NullResponse(await this.apiGetXml('scrobble', params))
}
async star(params: StarParams): Promise<NullResponse> {
return new NullResponse(await this.apiGetXml('star', params))
}
async unstar(params: StarParams): Promise<NullResponse> {
return new NullResponse(await this.apiGetXml('unstar', params))
}
//
// Searching
//
async search3(params: Search3Params): Promise<Search3Response> {
return new Search3Response(await this.apiGetXml('search3', params))
}
}
-273
View File
@@ -1,273 +0,0 @@
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, 10)
}
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
}
this.similarArtists = Array.from(e.getElementsByTagName('similarArtist')).map(i => new artistType(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')
}
}
export class PlaylistElement {
allowedUser: string[] = []
id: string
name: string
comment?: string
owner?: string
public?: boolean
songCount: number
duration: number
created: Date
changed: Date
coverArt?: string
constructor(e: Element) {
this.allowedUser = Array.from(e.getElementsByTagName('allowedUser')).map(i => i.textContent as string)
this.id = requiredString(e, 'id')
this.name = requiredString(e, 'name')
this.comment = optionalString(e, 'comment')
this.owner = optionalString(e, 'owner')
this.public = optionalBoolean(e, 'public')
this.songCount = requiredInt(e, 'songCount')
this.duration = requiredInt(e, 'duration')
this.created = requiredDate(e, 'created')
this.changed = requiredDate(e, 'changed')
this.coverArt = optionalString(e, 'coverArt')
}
}
export class PlaylistWithSongsElement extends PlaylistElement {
songs: ChildElement[] = []
constructor(e: Element) {
super(e)
this.songs = Array.from(e.getElementsByTagName('entry')).map(i => new ChildElement(i))
}
}
-137
View File
@@ -1,137 +0,0 @@
//
// 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
}
export type GetSongParams = {
id: string
}
export type GetTopSongsParams = {
artist: string
count?: number
}
//
// Album/song lists
//
export type GetAlbumList2TypeBase =
| 'random'
| 'newest'
| 'frequent'
| 'recent'
| 'starred'
| 'alphabeticalByName'
| 'alphabeticalByArtist'
export type GetAlbumListTypeBase = GetAlbumList2TypeBase | ' highest'
type GetAlbumListBase = {
size?: number
offset?: number
musicFolderId?: string
}
export type GetAlbumList2TypeByYear = GetAlbumListBase & {
type: 'byYear'
fromYear: number
toYear: number
}
export type GetAlbumList2TypeByGenre = GetAlbumListBase & {
type: 'byGenre'
genre: string
}
export type GetAlbumList2Params =
| (GetAlbumListBase & { type: GetAlbumList2TypeBase })
| GetAlbumList2TypeByYear
| GetAlbumList2TypeByGenre
export type GetAlbumListParams = GetAlbumList2Params
export type GetAlbumList2Type = GetAlbumList2TypeBase | 'byYear' | 'byGenre'
export type GetAlbumListType = GetAlbumListTypeBase | 'byYear' | 'byGenre'
//
// Playlists
//
export type GetPlaylistsParams = {
username?: string
}
export type GetPlaylistParams = {
id: string
}
//
// Media retrieval
//
export type GetCoverArtParams = {
id: string
size?: string
}
export type StreamParams = {
id: string
maxBitRate?: number
format?: string
estimateContentLength?: boolean
}
//
// Media annotation
//
export type ScrobbleParams = {
id: string
time?: Date
submission?: boolean
}
export type StarParams = {
id?: string
albumId?: string
artistId?: string
}
//
// Searching
//
export type Search3Params = {
query: string
artistCount?: number
artistOffset?: number
albumCount?: number
albumOffset?: number
songCount?: number
songOffset?: number
musicFolderId?: string
}
-255
View File
@@ -1,255 +0,0 @@
import {
AlbumID3Element,
ArtistElement,
ArtistID3Element,
ArtistInfo2Element,
ArtistInfoElement,
ChildElement,
DirectoryElement,
PlaylistElement,
PlaylistWithSongsElement,
} from '@app/subsonic/elements'
export type ResponseStatus = 'ok' | 'failed'
export class SubsonicResponse {
status: ResponseStatus
version: string
constructor(xml: Document) {
this.status = xml.documentElement.getAttribute('status') as ResponseStatus
this.version = xml.documentElement.getAttribute('version') as string
}
}
export class NullResponse extends SubsonicResponse {
data = null
}
//
// Browsing
//
export class GetArtistsResponse extends SubsonicResponse {
data: {
ignoredArticles: string
artists: ArtistID3Element[]
}
constructor(xml: Document) {
super(xml)
this.data = {
ignoredArticles: xml.getElementsByTagName('artists')[0].getAttribute('ignoredArticles') || '',
artists: Array.from(xml.getElementsByTagName('artist')).map(i => new ArtistID3Element(i)),
}
}
}
export class GetArtistResponse extends SubsonicResponse {
data: {
artist: ArtistID3Element
albums: AlbumID3Element[]
}
constructor(xml: Document) {
super(xml)
this.data = {
artist: new ArtistID3Element(xml.getElementsByTagName('artist')[0]),
albums: Array.from(xml.getElementsByTagName('album')).map(i => new AlbumID3Element(i)),
}
}
}
export class GetIndexesResponse extends SubsonicResponse {
data: {
ignoredArticles: string
lastModified: number
artists: ArtistElement[]
}
constructor(xml: Document) {
super(xml)
const indexesElement = xml.getElementsByTagName('indexes')[0]
this.data = {
ignoredArticles: indexesElement.getAttribute('ignoredArticles') || '',
lastModified: parseInt(indexesElement.getAttribute('lastModified') || '0', 10),
artists: Array.from(xml.getElementsByTagName('artist')).map(i => new ArtistElement(i)),
}
}
}
export class GetArtistInfoResponse extends SubsonicResponse {
data: {
artistInfo: ArtistInfoElement
}
constructor(xml: Document) {
super(xml)
this.data = {
artistInfo: new ArtistInfoElement(xml.getElementsByTagName('artistInfo')[0]),
}
}
}
export class GetArtistInfo2Response extends SubsonicResponse {
data: {
artistInfo: ArtistInfo2Element
}
constructor(xml: Document) {
super(xml)
this.data = {
artistInfo: new ArtistInfo2Element(xml.getElementsByTagName('artistInfo2')[0]),
}
}
}
export class GetMusicDirectoryResponse extends SubsonicResponse {
data: {
directory: DirectoryElement
children: ChildElement[]
}
constructor(xml: Document) {
super(xml)
this.data = {
directory: new DirectoryElement(xml.getElementsByTagName('directory')[0]),
children: Array.from(xml.getElementsByTagName('child')).map(i => new ChildElement(i)),
}
}
}
export class GetAlbumResponse extends SubsonicResponse {
data: {
album: AlbumID3Element
songs: ChildElement[]
}
constructor(xml: Document) {
super(xml)
this.data = {
album: new AlbumID3Element(xml.getElementsByTagName('album')[0]),
songs: Array.from(xml.getElementsByTagName('song')).map(i => new ChildElement(i)),
}
}
}
export class GetTopSongsResponse extends SubsonicResponse {
data: {
songs: ChildElement[]
}
constructor(xml: Document) {
super(xml)
this.data = {
songs: Array.from(xml.getElementsByTagName('song')).map(i => new ChildElement(i)),
}
}
}
export class GetSongResponse extends SubsonicResponse {
data: {
song: ChildElement
}
constructor(xml: Document) {
super(xml)
this.data = {
song: new ChildElement(xml.getElementsByTagName('song')[0]),
}
}
}
//
// Album/song lists
//
class BaseGetAlbumListResponse<T> extends SubsonicResponse {
data: {
albums: T[]
}
constructor(xml: Document, AlbumType: new (e: Element) => T) {
super(xml)
this.data = {
albums: Array.from(xml.getElementsByTagName('album')).map(i => new AlbumType(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)
}
}
//
// Playlists
//
export class GetPlaylistsResponse extends SubsonicResponse {
data: {
playlists: PlaylistElement[]
}
constructor(xml: Document) {
super(xml)
this.data = {
playlists: Array.from(xml.getElementsByTagName('playlist')).map(i => new PlaylistElement(i)),
}
}
}
export class GetPlaylistResponse extends SubsonicResponse {
data: {
playlist: PlaylistWithSongsElement
}
constructor(xml: Document) {
super(xml)
this.data = {
playlist: new PlaylistWithSongsElement(xml.getElementsByTagName('playlist')[0]),
}
}
}
//
// Searching
//
export class Search3Response extends SubsonicResponse {
data: {
artists: ArtistID3Element[]
albums: AlbumID3Element[]
songs: ChildElement[]
}
constructor(xml: Document) {
super(xml)
this.data = {
artists: Array.from(xml.getElementsByTagName('artist')).map(i => new ArtistID3Element(i)),
albums: Array.from(xml.getElementsByTagName('album')).map(i => new AlbumID3Element(i)),
songs: Array.from(xml.getElementsByTagName('song')).map(i => new ChildElement(i)),
}
}
}
-18
View File
@@ -1,18 +0,0 @@
/* eslint-disable no-dupe-class-members */
import { EmitterSubscription, NativeEventEmitter } from 'react-native'
import { TrackExt } from '@app/models/trackplayer'
class QueueEventEmitter extends NativeEventEmitter {
addListener(eventType: 'set', listener: (event: { queue: TrackExt[] }) => void): EmitterSubscription
addListener(eventType: string, listener: (event: any) => void, context?: Object): EmitterSubscription {
return super.addListener(eventType, listener, context)
}
emit(eventType: 'set', event: { queue: TrackExt[] }): void
emit(eventType: string, ...params: any[]): void {
super.emit(eventType, ...params)
}
}
const QueueEvents = new QueueEventEmitter()
export default QueueEvents
-300
View File
@@ -1,300 +0,0 @@
import { fetchAlbum } from '@app/query/fetch/api'
import { FetchExisingFileOptions, fetchExistingFile, fetchFile, FetchFileOptions } from '@app/query/fetch/file'
import qk from '@app/query/queryKeys'
import { getCurrentTrack, getPlayerState, trackPlayerCommands } from '@app/state/trackplayer'
import NetInfo, { NetInfoStateType } from '@react-native-community/netinfo'
import _ from 'lodash'
import { unstable_batchedUpdates } from 'react-native'
import TrackPlayer, { Event, State } from 'react-native-track-player'
import queryClient from '../query/queryClient'
import { useStore } from '../state/store'
import { ReturnedPromiseResolvedType } from '../util/types'
import QueueEvents from './QueueEvents'
const reset = () => {
unstable_batchedUpdates(() => {
useStore.getState().resetTrackPlayerState()
})
}
const setPlayerState = (state: State) => {
unstable_batchedUpdates(() => {
useStore.getState().setPlayerState(state)
})
}
const setCurrentTrackIdx = (idx?: number) => {
unstable_batchedUpdates(() => {
useStore.getState().setCurrentTrackIdx(idx)
})
}
const setNetState = (netState: 'mobile' | 'wifi') => {
unstable_batchedUpdates(() => {
useStore.getState().setNetState(netState)
})
}
const rebuildQueue = (forcePlay?: boolean) => {
unstable_batchedUpdates(() => {
useStore.getState().rebuildQueue(forcePlay)
})
}
const updateQueue = () => {
unstable_batchedUpdates(() => {
useStore.getState().updateQueue()
})
}
const setDuckPaused = (duckPaused: boolean) => {
unstable_batchedUpdates(() => {
useStore.getState().setDuckPaused(duckPaused)
})
}
const setQueryDataAlbum = (queryKey: any, data: ReturnedPromiseResolvedType<typeof fetchAlbum>) => {
unstable_batchedUpdates(() => {
queryClient.setQueryData(queryKey, data)
})
}
const setQueryDataExistingFiles = (queryKey: any, data: ReturnedPromiseResolvedType<typeof fetchExistingFile>) => {
unstable_batchedUpdates(() => {
queryClient.setQueryData(queryKey, data)
})
}
const setQueryDataCoverArt = (queryKey: any, data: ReturnedPromiseResolvedType<typeof fetchFile>) => {
unstable_batchedUpdates(() => {
queryClient.setQueryData(queryKey, data)
})
}
function getClient() {
const client = useStore.getState().client
if (!client) {
throw new Error('no client!')
}
return client
}
async function getAlbum(id: string) {
try {
const res = await fetchAlbum(id, getClient())
setQueryDataAlbum(qk.album(id), res)
return res
} catch {}
}
async function getCoverArtThumbExisting(coverArt: string) {
const serverId = useStore.getState().settings.activeServerId
const options: FetchExisingFileOptions = { itemType: 'coverArtThumb', itemId: coverArt }
try {
const res = await fetchExistingFile(options, serverId)
setQueryDataExistingFiles(qk.existingFiles(options.itemType, options.itemId), res)
return res
} catch {}
}
async function getCoverArtThumb(coverArt: string) {
const serverId = useStore.getState().settings.activeServerId
const fromUrl = getClient().getCoverArtUri({ id: coverArt, size: '256' })
const options: FetchFileOptions = {
itemType: 'coverArtThumb',
itemId: coverArt,
fromUrl,
expectedContentType: 'image',
}
try {
const res = await fetchFile(options, serverId)
setQueryDataCoverArt(qk.coverArt(coverArt, 'thumbnail'), res)
return res
} catch {}
}
let serviceCreated = false
const createService = async () => {
useStore.subscribe(
state => state.currentTrack?.id,
(currentTrackId?: string) => {
if (currentTrackId) {
useStore.getState().scrobbleTrack(currentTrackId)
}
},
)
NetInfo.fetch().then(state => {
setNetState(state.type === NetInfoStateType.cellular ? 'mobile' : 'wifi')
})
NetInfo.addEventListener(state => {
const currentType = useStore.getState().netState
const newType = state.type === NetInfoStateType.cellular ? 'mobile' : 'wifi'
if (currentType !== newType) {
setNetState(newType)
}
})
TrackPlayer.addEventListener(Event.RemoteStop, () => {
reset()
trackPlayerCommands.enqueue(TrackPlayer.destroy)
})
TrackPlayer.addEventListener(Event.RemotePlay, () => trackPlayerCommands.enqueue(TrackPlayer.play))
TrackPlayer.addEventListener(Event.RemotePause, () => trackPlayerCommands.enqueue(TrackPlayer.pause))
TrackPlayer.addEventListener(Event.RemoteNext, () =>
trackPlayerCommands.enqueue(() => TrackPlayer.skipToNext().catch(() => {})),
)
TrackPlayer.addEventListener(Event.RemotePrevious, () =>
trackPlayerCommands.enqueue(() => TrackPlayer.skipToPrevious().catch(() => {})),
)
TrackPlayer.addEventListener(Event.RemoteDuck, data => {
if (data.permanent) {
trackPlayerCommands.enqueue(TrackPlayer.stop)
return
}
if (data.paused) {
let state = useStore.getState().playerState
if (state === State.Playing || state === State.Buffering || state === State.Connecting) {
trackPlayerCommands.enqueue(TrackPlayer.pause)
setDuckPaused(true)
}
} else if (useStore.getState().duckPaused) {
trackPlayerCommands.enqueue(TrackPlayer.play)
setDuckPaused(false)
}
})
TrackPlayer.addEventListener(Event.PlaybackState, () => {
trackPlayerCommands.enqueue(async () => {
setPlayerState(await getPlayerState())
})
})
TrackPlayer.addEventListener(Event.PlaybackTrackChanged, () => {
useStore.getState().setProgress({ position: 0, duration: 0, buffered: 0 })
trackPlayerCommands.enqueue(async () => {
setCurrentTrackIdx(await getCurrentTrack())
})
})
TrackPlayer.addEventListener(Event.PlaybackQueueEnded, event => {
const { position, track } = event
// bogus event that fires when queue is changed
if (!track && position === 0) {
return
}
trackPlayerCommands.enqueue(async () => {
await TrackPlayer.stop()
await TrackPlayer.skip(0)
})
})
TrackPlayer.addEventListener(Event.PlaybackMetadataReceived, () => {
setCurrentTrackIdx(useStore.getState().currentTrackIdx)
})
TrackPlayer.addEventListener(Event.RemoteSeek, data => {
trackPlayerCommands.enqueue(async () => {
await TrackPlayer.seekTo(data.position)
})
})
TrackPlayer.addEventListener(Event.PlaybackError, data => {
const { code, message } = data as Record<string, string>
// fix for ExoPlayer aborting playback while esimating content length
if (code === 'playback-source' && message.includes('416')) {
rebuildQueue(true)
}
})
QueueEvents.addListener('set', async ({ queue }) => {
const contextId = useStore.getState().queueContextId
const throwIfQueueChanged = () => {
if (contextId !== useStore.getState().queueContextId) {
throw 'queue-changed'
}
}
const albumIds = _.uniq(queue.map(s => s.albumId)).filter((id): id is string => id !== undefined)
const albumIdImagePath: { [albumId: string]: string | undefined } = {}
for (const albumId of albumIds) {
let coverArt = queryClient.getQueryData<string>(qk.albumCoverArt(albumId))
if (!coverArt) {
throwIfQueueChanged()
console.log('no cached coverArt for album', albumId, 'getting album...')
coverArt = (await getAlbum(albumId))?.album.coverArt
if (!coverArt) {
continue
}
}
let imagePath =
queryClient.getQueryData<string>(qk.existingFiles('coverArtThumb', coverArt)) ||
queryClient.getQueryData<string>(qk.coverArt(coverArt, 'thumbnail'))
if (!imagePath) {
throwIfQueueChanged()
console.log('no cached image for', coverArt, 'getting file...')
imagePath = (await getCoverArtThumbExisting(coverArt)) || (await getCoverArtThumb(coverArt))
if (!imagePath) {
continue
}
}
albumIdImagePath[albumId] = imagePath
}
for (let i = 0; i < queue.length; i++) {
const track = queue[i]
if (typeof track.artwork === 'string') {
continue
}
if (!track.albumId) {
continue
}
let imagePath = albumIdImagePath[track.albumId]
if (!imagePath) {
continue
}
try {
throwIfQueueChanged()
let trackIdx = i
const shuffleOrder = useStore.getState().shuffleOrder
if (shuffleOrder) {
trackIdx = shuffleOrder.indexOf(i)
}
await TrackPlayer.updateMetadataForTrack(trackIdx, { ...track, artwork: `file://${imagePath}` })
} catch {
break
}
}
await trackPlayerCommands.enqueue(async () => {
updateQueue()
})
})
}
module.exports = async function () {
if (!serviceCreated) {
createService()
serviceCreated = true
}
}
-31
View File
@@ -1,31 +0,0 @@
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
-17
View File
@@ -1,17 +0,0 @@
import RNFS from 'react-native-fs'
import path from 'path'
import { CacheItemTypeKey } from '@app/models/cache'
const serversCacheDir = path.join(RNFS.ExternalDirectoryPath, 's')
function cacheDir(serverId?: string, itemType?: CacheItemTypeKey, itemId?: string): string {
const segments: string[] = []
serverId && segments.push(serverId)
serverId && itemType && segments.push(itemType)
serverId && itemType && itemId && segments.push(itemId)
return path.join(serversCacheDir, ...segments)
}
export default cacheDir
-13
View File
@@ -1,13 +0,0 @@
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
-32
View File
@@ -1,32 +0,0 @@
import { ById, CollectionById } from '@app/models/state'
import _ from 'lodash'
export function reduceById<T extends { id: string }>(collection: T[]): ById<T> {
return collection.reduce((acc, value) => {
acc[value.id] = value
return acc
}, {} as ById<T>)
}
export function mergeById<T extends { [id: string]: unknown }>(object: T, source: T): void {
_.merge(object, source)
}
export function mapById<T>(object: ById<T>, ids: string[]): T[] {
return ids.map(id => object[id]).filter(a => a !== undefined)
}
export function mapId(entities: { id: string }[]): string[] {
return entities.map(e => e.id)
}
export function mapCollectionById<T, U extends { id: string }>(
collection: T[],
map: (item: T) => U,
): CollectionById<U> {
const mapped = collection.map(map)
return {
byId: reduceById(mapped),
allIds: mapId(mapped),
}
}
-7
View File
@@ -1,7 +0,0 @@
import { ToastAndroid } from 'react-native'
function toast(message: string) {
ToastAndroid.showWithGravityAndOffset(message, ToastAndroid.SHORT, ToastAndroid.BOTTOM, 0, 180)
}
export default toast
-2
View File
@@ -1,2 +0,0 @@
export type PromiseResolvedType<T> = T extends Promise<infer R> ? R : never
export type ReturnedPromiseResolvedType<T extends (...args: any) => any> = PromiseResolvedType<ReturnType<T>>
-3
View File
@@ -1,3 +0,0 @@
import { Platform } from 'react-native'
export default `subtracks/${Platform.OS}`