mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 23:21:59 +01:00
v2
This commit is contained in:
-47
@@ -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
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"name": "subtracks",
|
||||
"displayName": "Subtracks"
|
||||
}
|
||||
@@ -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
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }),
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,5 +0,0 @@
|
||||
export class NoClientError extends Error {
|
||||
constructor() {
|
||||
super('no client in state')
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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[]
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { QueryClient } from 'react-query'
|
||||
|
||||
const client = new QueryClient()
|
||||
|
||||
export default client
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -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)
|
||||
@@ -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)])
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
export default {
|
||||
text: {
|
||||
primary: '#ffffff',
|
||||
secondary: '#999999',
|
||||
},
|
||||
gradient: {
|
||||
high: '#2d2d2d',
|
||||
low: '#000000',
|
||||
},
|
||||
accent: '#b134db',
|
||||
accentLow: '#511c63',
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
const header = 56
|
||||
const tabBar = 54
|
||||
|
||||
const bottom = () => tabBar
|
||||
|
||||
export default {
|
||||
header,
|
||||
tabBar,
|
||||
bottom,
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
enum font {
|
||||
regular = 'Metropolis-Regular',
|
||||
semiBold = 'Metropolis-SemiBold',
|
||||
bold = 'Metropolis-Bold',
|
||||
lightItalic = 'Metropolis-SemiBoldItalic',
|
||||
}
|
||||
|
||||
export default font
|
||||
@@ -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'),
|
||||
},
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>>
|
||||
@@ -1,3 +0,0 @@
|
||||
import { Platform } from 'react-native'
|
||||
|
||||
export default `subtracks/${Platform.OS}`
|
||||
Reference in New Issue
Block a user