reorg again, absolute (module) imports

This commit is contained in:
austinried
2021-07-08 12:21:44 +09:00
parent a94a011a18
commit ea4421b7af
54 changed files with 186 additions and 251 deletions

194
app/screens/AlbumView.tsx Normal file
View File

@@ -0,0 +1,194 @@
import { useNavigation } from '@react-navigation/native'
import { useAtomValue } from 'jotai/utils'
import React, { useEffect } from 'react'
import { ActivityIndicator, GestureResponderEvent, StyleSheet, Text, useWindowDimensions, View } from 'react-native'
import IconFA from 'react-native-vector-icons/FontAwesome'
import IconMat from 'react-native-vector-icons/MaterialIcons'
import { albumAtomFamily } from '@app/state/music'
import { currentTrackAtom, useSetQueue } from '@app/state/trackplayer'
import colors from '@app/styles/colors'
import text, { Font } from '@app/styles/text'
import AlbumArt from '@app/components/AlbumArt'
import Button from '@app/components/Button'
import GradientBackground from '@app/components/GradientBackground'
import ImageGradientScrollView from '@app/components/ImageGradientScrollView'
import PressableOpacity from '@app/components/PressableOpacity'
const SongItem: React.FC<{
id: string
title: string
artist?: string
track?: number
onPress: (event: GestureResponderEvent) => void
}> = ({ id, title, artist, onPress }) => {
const currentTrack = useAtomValue(currentTrackAtom)
return (
<View style={songStyles.container}>
<PressableOpacity onPress={onPress} style={songStyles.text}>
<Text style={{ ...songStyles.title, color: currentTrack?.id === id ? colors.accent : colors.text.primary }}>
{title}
</Text>
<Text style={songStyles.subtitle}>{artist}</Text>
</PressableOpacity>
<View style={songStyles.controls}>
<PressableOpacity onPress={undefined}>
<IconFA name="star-o" size={26} color={colors.text.primary} />
</PressableOpacity>
<PressableOpacity onPress={undefined} style={songStyles.more}>
<IconMat name="more-vert" size={32} color="white" />
</PressableOpacity>
</View>
</View>
)
}
const songStyles = StyleSheet.create({
container: {
marginTop: 20,
marginLeft: 10,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
text: {
flex: 1,
alignItems: 'flex-start',
},
title: {
fontSize: 16,
fontFamily: Font.semiBold,
},
subtitle: {
fontSize: 14,
fontFamily: Font.regular,
color: colors.text.secondary,
},
controls: {
flexDirection: 'row',
alignItems: 'center',
marginLeft: 10,
},
more: {
marginLeft: 8,
},
})
const AlbumDetails: React.FC<{
id: string
}> = ({ id }) => {
const album = useAtomValue(albumAtomFamily(id))
const layout = useWindowDimensions()
const setQueue = useSetQueue()
const coverSize = layout.width - layout.width / 2.5
if (!album) {
return <Text style={text.paragraph}>No Album</Text>
}
return (
<ImageGradientScrollView
imageUri={album.coverArtThumbUri}
imageKey={`${album.name}${album.artist}`}
style={{
flex: 1,
}}
contentContainerStyle={{
alignItems: 'center',
paddingTop: coverSize / 8,
}}>
<AlbumArt id={album.id} height={coverSize} width={coverSize} />
<Text
style={{
...text.title,
marginTop: 12,
width: layout.width - layout.width / 8,
textAlign: 'center',
}}>
{album.name}
</Text>
<Text
style={{
...text.itemSubtitle,
fontSize: 14,
marginTop: 4,
marginBottom: 20,
width: layout.width - layout.width / 8,
textAlign: 'center',
}}>
{album.artist}
{album.year ? `${album.year}` : ''}
</Text>
<View
style={{
flexDirection: 'row',
}}>
<Button title="Play Album" onPress={() => setQueue(album.songs, album.name, album.songs[0].id)} />
</View>
<View
style={{
width: layout.width - layout.width / 20,
marginTop: 20,
marginBottom: 30,
}}>
{album.songs
.sort((a, b) => {
if (b.track && a.track) {
return a.track - b.track
} else {
return a.title.localeCompare(b.title)
}
})
.map(s => (
<SongItem
key={s.id}
id={s.id}
title={s.title}
artist={s.artist}
track={s.track}
onPress={() => setQueue(album.songs, album.name, s.id)}
/>
))}
</View>
</ImageGradientScrollView>
)
}
const AlbumViewFallback = () => {
const layout = useWindowDimensions()
const coverSize = layout.width - layout.width / 2.5
return (
<GradientBackground
style={{
alignItems: 'center',
paddingTop: coverSize / 8 + coverSize / 2 - 18,
}}>
<ActivityIndicator size="large" color={colors.accent} />
</GradientBackground>
)
}
const AlbumView: React.FC<{
id: string
title: string
}> = ({ id, title }) => {
const navigation = useNavigation()
useEffect(() => {
navigation.setOptions({ title })
})
return (
<React.Suspense fallback={<AlbumViewFallback />}>
<AlbumDetails id={id} />
</React.Suspense>
)
}
export default React.memo(AlbumView)

View File

@@ -0,0 +1,49 @@
import { useNavigation } from '@react-navigation/native'
import { useAtomValue } from 'jotai/utils'
import React, { useEffect } from 'react'
import { Text } from 'react-native'
import { artistInfoAtomFamily } from '@app/state/music'
import text from '@app/styles/text'
import ArtistArt from '@app/components/ArtistArt'
import GradientScrollView from '@app/components/GradientScrollView'
const ArtistDetails: React.FC<{ id: string }> = ({ id }) => {
const artist = useAtomValue(artistInfoAtomFamily(id))
if (!artist) {
return <></>
}
return (
<GradientScrollView
style={{
flex: 1,
}}
contentContainerStyle={{
alignItems: 'center',
// paddingTop: coverSize / 8,
}}>
<Text style={text.paragraph}>{artist.name}</Text>
<ArtistArt id={artist.id} height={200} width={200} />
</GradientScrollView>
)
}
const ArtistView: React.FC<{
id: string
title: string
}> = ({ id, title }) => {
const navigation = useNavigation()
useEffect(() => {
navigation.setOptions({ title })
})
return (
<React.Suspense fallback={<Text>Loading...</Text>}>
<ArtistDetails id={id} />
</React.Suspense>
)
}
export default React.memo(ArtistView)

View File

@@ -0,0 +1,36 @@
import React from 'react'
import { FlatList, Text, View } from 'react-native'
import { useAtomValue } from 'jotai/utils'
import { Artist } from '@app/models/music'
import { artistsAtom } from '@app/state/music'
const ArtistItem: React.FC<{ item: Artist }> = ({ item }) => (
<View>
<Text>{item.id}</Text>
<Text
style={{
fontSize: 60,
paddingBottom: 400,
}}>
{item.name}
</Text>
</View>
)
const List = () => {
const artists = useAtomValue(artistsAtom)
const renderItem: React.FC<{ item: Artist }> = ({ item }) => <ArtistItem item={item} />
return <FlatList data={artists} renderItem={renderItem} keyExtractor={item => item.id} />
}
const ArtistsList = () => (
<View>
<React.Suspense fallback={<Text>Loading...</Text>}>
<List />
</React.Suspense>
</View>
)
export default ArtistsList

View File

@@ -0,0 +1,90 @@
import { useNavigation } from '@react-navigation/native'
import { useAtomValue } from 'jotai/utils'
import React, { useEffect } from 'react'
import { Pressable, Text, View } from 'react-native'
import { Album } from '@app/models/music'
import { albumsAtom, albumsUpdatingAtom, useUpdateAlbums } from '@app/state/music'
import textStyles from '@app/styles/text'
import AlbumArt from '@app/components/AlbumArt'
import GradientFlatList from '@app/components/GradientFlatList'
const AlbumItem: React.FC<{
id: string
name: string
artist?: string
}> = ({ id, name, artist }) => {
const navigation = useNavigation()
const size = 125
return (
<Pressable
style={{
alignItems: 'center',
marginVertical: 8,
flex: 1 / 3,
}}
onPress={() => navigation.navigate('AlbumView', { id, title: name })}>
<AlbumArt id={id} height={size} width={size} />
<View
style={{
flex: 1,
width: size,
}}>
<Text
style={{
...textStyles.itemTitle,
marginTop: 4,
}}
numberOfLines={2}>
{name}
</Text>
<Text style={{ ...textStyles.itemSubtitle }} numberOfLines={1}>
{artist}
</Text>
</View>
</Pressable>
)
}
const MemoAlbumItem = React.memo(AlbumItem)
const AlbumListRenderItem: React.FC<{ item: Album }> = ({ item }) => (
<MemoAlbumItem id={item.id} name={item.name} artist={item.artist} />
)
const AlbumsList = () => {
const albums = useAtomValue(albumsAtom)
const updating = useAtomValue(albumsUpdatingAtom)
const updateAlbums = useUpdateAlbums()
const albumsList = Object.values(albums)
useEffect(() => {
if (albumsList.length === 0) {
updateAlbums()
}
})
return (
<View style={{ flex: 1 }}>
<GradientFlatList
data={albumsList}
renderItem={AlbumListRenderItem}
keyExtractor={item => item.id}
numColumns={3}
removeClippedSubviews={true}
refreshing={updating}
onRefresh={updateAlbums}
overScrollMode="never"
/>
</View>
)
}
const AlbumsTab = () => (
<React.Suspense fallback={<Text>Loading...</Text>}>
<AlbumsList />
</React.Suspense>
)
export default React.memo(AlbumsTab)

View File

@@ -0,0 +1,69 @@
import { useNavigation } from '@react-navigation/native'
import { useAtomValue } from 'jotai/utils'
import React, { useEffect } from 'react'
import { Pressable } from 'react-native'
import { Text } from 'react-native'
import { Artist } from '@app/models/music'
import { artistsAtom, artistsUpdatingAtom, useUpdateArtists } from '@app/state/music'
import textStyles from '@app/styles/text'
import ArtistArt from '@app/components/ArtistArt'
import GradientFlatList from '@app/components/GradientFlatList'
const ArtistItem: React.FC<{ item: Artist }> = ({ item }) => {
const navigation = useNavigation()
return (
<Pressable
style={{
flexDirection: 'row',
alignItems: 'center',
marginVertical: 6,
marginLeft: 6,
}}
onPress={() => navigation.navigate('ArtistView', { id: item.id, title: item.name })}>
<ArtistArt id={item.id} width={56} height={56} />
<Text
style={{
...textStyles.paragraph,
marginLeft: 12,
}}>
{item.name}
</Text>
</Pressable>
)
}
const ArtistItemLoader: React.FC<{ item: Artist }> = props => (
<React.Suspense fallback={<Text>Loading...</Text>}>
<ArtistItem {...props} />
</React.Suspense>
)
const ArtistsList = () => {
const artists = useAtomValue(artistsAtom)
const updating = useAtomValue(artistsUpdatingAtom)
const updateArtists = useUpdateArtists()
useEffect(() => {
if (artists.length === 0) {
updateArtists()
}
})
const renderItem: React.FC<{ item: Artist }> = ({ item }) => <ArtistItemLoader item={item} />
return (
<GradientFlatList
data={artists}
renderItem={renderItem}
keyExtractor={item => item.id}
onRefresh={updateArtists}
refreshing={updating}
overScrollMode="never"
/>
)
}
const ArtistsTab = () => <ArtistsList />
export default ArtistsTab

View File

@@ -0,0 +1,6 @@
import React from 'react'
import GradientBackground from '@app/components/GradientBackground'
const PlaylistsTab = () => <GradientBackground />
export default PlaylistsTab

View File

@@ -0,0 +1,340 @@
import { useNavigation } from '@react-navigation/native'
import { useAtomValue } from 'jotai/utils'
import React, { useEffect } from 'react'
import { StatusBar, StyleSheet, Text, View } from 'react-native'
import { NativeStackScreenProps } from 'react-native-screens/lib/typescript/native-stack'
import { State } from 'react-native-track-player'
import IconFA from 'react-native-vector-icons/FontAwesome'
import IconFA5 from 'react-native-vector-icons/FontAwesome5'
import Icon from 'react-native-vector-icons/Ionicons'
import IconMatCom from 'react-native-vector-icons/MaterialCommunityIcons'
import IconMat from 'react-native-vector-icons/MaterialIcons'
import {
currentTrackAtom,
playerStateAtom,
queueNameAtom,
useNext,
usePause,
usePlay,
usePrevious,
useProgress,
} from '@app/state/trackplayer'
import colors from '@app/styles/colors'
import { Font } from '@app/styles/text'
import formatDuration from '@app/util/formatDuration'
import CoverArt from '@app/components/CoverArt'
import ImageGradientBackground from '@app/components/ImageGradientBackground'
import PressableOpacity from '@app/components/PressableOpacity'
const NowPlayingHeader = () => {
const queueName = useAtomValue(queueNameAtom)
const navigation = useNavigation()
return (
<View style={headerStyles.container}>
<PressableOpacity onPress={() => navigation.goBack()} style={headerStyles.icons} ripple={true}>
<IconMat name="arrow-back" color="white" size={25} />
</PressableOpacity>
<Text numberOfLines={1} style={headerStyles.queueName}>
{queueName || 'Nothing playing...'}
</Text>
<PressableOpacity onPress={undefined} style={headerStyles.icons} ripple={true}>
<IconMat name="more-vert" color="white" size={25} />
</PressableOpacity>
</View>
)
}
const headerStyles = StyleSheet.create({
container: {
height: 58,
width: '100%',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
icons: {
height: 42,
width: 42,
marginHorizontal: 8,
},
queueName: {
fontFamily: Font.bold,
fontSize: 16,
color: colors.text.primary,
flex: 1,
textAlign: 'center',
},
})
const SongCoverArt = () => {
const track = useAtomValue(currentTrackAtom)
return (
<View style={coverArtStyles.container}>
<CoverArt
PlaceholderComponent={() => <View style={{ height: '100%', width: '100%' }} />}
height={'100%'}
width={'100%'}
coverArtUri={track?.artwork as string}
/>
</View>
)
}
const coverArtStyles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
paddingBottom: 20,
},
})
const SongInfo = () => {
const track = useAtomValue(currentTrackAtom)
return (
<View style={infoStyles.container}>
<View style={infoStyles.details}>
<Text numberOfLines={1} style={infoStyles.title}>
{track?.title}
</Text>
<Text numberOfLines={1} style={infoStyles.artist}>
{track?.artist}
</Text>
</View>
<View style={infoStyles.controls}>
<PressableOpacity onPress={undefined}>
<IconFA name="star-o" size={32} color={colors.text.secondary} />
</PressableOpacity>
</View>
</View>
)
}
const infoStyles = StyleSheet.create({
container: {
width: '100%',
flexDirection: 'row',
},
details: {
flex: 1,
marginRight: 20,
},
controls: {
justifyContent: 'center',
},
title: {
height: 28,
fontFamily: Font.bold,
fontSize: 22,
color: colors.text.primary,
},
artist: {
height: 20,
fontFamily: Font.regular,
fontSize: 16,
color: colors.text.secondary,
},
})
const SeekBar = () => {
const { position, duration } = useProgress()
let progress = 0
if (duration > 0) {
progress = position / duration
}
return (
<View style={seekStyles.container}>
<View style={seekStyles.barContainer}>
<View style={{ ...seekStyles.bars, ...seekStyles.barLeft, flex: progress }} />
<View style={{ ...seekStyles.indicator }} />
<View style={{ ...seekStyles.bars, ...seekStyles.barRight, flex: 1 - progress }} />
</View>
<View style={seekStyles.textContainer}>
<Text style={seekStyles.text}>{formatDuration(position)}</Text>
<Text style={seekStyles.text}>{formatDuration(duration)}</Text>
</View>
</View>
)
}
const seekStyles = StyleSheet.create({
container: {
width: '100%',
marginTop: 26,
},
barContainer: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 6,
},
bars: {
backgroundColor: colors.text.primary,
height: 4,
},
barLeft: {
marginRight: -6,
},
barRight: {
opacity: 0.3,
marginLeft: -6,
},
indicator: {
height: 12,
width: 12,
borderRadius: 6,
backgroundColor: colors.text.primary,
elevation: 1,
},
textContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
},
text: {
fontFamily: Font.regular,
fontSize: 15,
color: colors.text.primary,
},
})
const PlayerControls = () => {
const state = useAtomValue(playerStateAtom)
const play = usePlay()
const pause = usePause()
const next = useNext()
const previous = usePrevious()
let playPauseIcon: string
let playPauseAction: undefined | (() => void)
let disabled: boolean
switch (state) {
case State.Playing:
case State.Buffering:
case State.Connecting:
disabled = false
playPauseIcon = 'pause-circle'
playPauseAction = pause
break
case State.Paused:
disabled = false
playPauseIcon = 'play-circle'
playPauseAction = play
break
default:
disabled = true
playPauseIcon = 'play-circle'
playPauseAction = undefined
break
}
return (
<View style={controlsStyles.container}>
<View style={controlsStyles.top}>
<View style={controlsStyles.center}>
<PressableOpacity onPress={undefined} disabled={disabled}>
<Icon name="repeat" size={26} color="white" />
</PressableOpacity>
</View>
<View style={controlsStyles.center}>
<PressableOpacity onPress={previous} disabled={disabled}>
<IconFA5 name="step-backward" size={36} color="white" />
</PressableOpacity>
<PressableOpacity onPress={playPauseAction} disabled={disabled} style={controlsStyles.play}>
<IconFA name={playPauseIcon} size={82} color="white" />
</PressableOpacity>
<PressableOpacity onPress={next} disabled={disabled}>
<IconFA5 name="step-forward" size={36} color="white" />
</PressableOpacity>
</View>
<View style={controlsStyles.center}>
<PressableOpacity onPress={undefined} disabled={disabled}>
<Icon name="shuffle" size={26} color="white" />
</PressableOpacity>
</View>
</View>
<View style={controlsStyles.bottom}>
<PressableOpacity onPress={undefined} disabled={disabled}>
<IconMatCom name="cast-audio" size={20} color="white" />
</PressableOpacity>
<PressableOpacity onPress={undefined} disabled={disabled}>
<IconMatCom name="playlist-play" size={24} color="white" />
</PressableOpacity>
</View>
</View>
)
}
const controlsStyles = StyleSheet.create({
container: {
width: '100%',
},
top: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingBottom: 8,
},
bottom: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingTop: 10,
paddingBottom: 34,
},
play: {
marginHorizontal: 30,
},
center: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
},
})
type RootStackParamList = {
Main: undefined
NowPlaying: undefined
}
type NowPlayingProps = NativeStackScreenProps<RootStackParamList, 'NowPlaying'>
const NowPlayingLayout: React.FC<NowPlayingProps> = ({ navigation }) => {
const track = useAtomValue(currentTrackAtom)
useEffect(() => {
if (!track && navigation.canGoBack()) {
navigation.popToTop()
}
})
return (
<View style={styles.container}>
<ImageGradientBackground imageUri={track?.artworkThumb as string} imageKey={`${track?.album}${track?.artist}`} />
<NowPlayingHeader />
<View style={styles.content}>
<SongCoverArt />
<SongInfo />
<SeekBar />
<PlayerControls />
</View>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
paddingTop: StatusBar.currentHeight,
},
content: {
flex: 1,
paddingHorizontal: 30,
},
})
export default NowPlayingLayout

77
app/screens/Settings.tsx Normal file
View File

@@ -0,0 +1,77 @@
import { useNavigation } from '@react-navigation/core'
import { useAtom } from 'jotai'
import md5 from 'md5'
import React from 'react'
import { Button, Text, View } from 'react-native'
import { v4 as uuidv4 } from 'uuid'
import { appSettingsAtom } from '@app/state/settings'
import { getAllKeys, multiRemove } from '@app/storage/asyncstorage'
import text from '@app/styles/text'
const TestControls = () => {
const navigation = useNavigation()
const removeAllKeys = async () => {
const allKeys = await getAllKeys()
await multiRemove(allKeys)
}
return (
<View>
<Button title="Remove all keys" onPress={removeAllKeys} />
<Button title="Now Playing" onPress={() => navigation.navigate('NowPlaying')} />
</View>
)
}
const ServerSettingsView = () => {
const [appSettings, setAppSettings] = useAtom(appSettingsAtom)
const bootstrapServer = () => {
if (appSettings.servers.length !== 0) {
return
}
const id = uuidv4()
const salt = uuidv4()
const address = 'http://demo.subsonic.org'
setAppSettings({
...appSettings,
servers: [
...appSettings.servers,
{
id,
salt,
address,
username: 'guest',
token: md5('guest' + salt),
},
],
activeServer: id,
})
}
return (
<View>
<Button title="Add default server" onPress={bootstrapServer} />
{appSettings.servers.map(s => (
<View key={s.id}>
<Text style={text.paragraph}>{s.address}</Text>
<Text style={text.paragraph}>{s.username}</Text>
</View>
))}
</View>
)
}
const SettingsView = () => (
<View>
<TestControls />
<React.Suspense fallback={<Text>Loading...</Text>}>
<ServerSettingsView />
</React.Suspense>
</View>
)
export default SettingsView

View File

@@ -0,0 +1,45 @@
import React, { useEffect, useState } from 'react'
import { Text, View } from 'react-native'
import RNFS from 'react-native-fs'
import paths from '@app/util/paths'
async function mkdir(path: string): Promise<void> {
const exists = await RNFS.exists(path)
if (exists) {
const isDir = (await RNFS.stat(path)).isDirectory()
if (!isDir) {
throw new Error(`path exists and is not a directory: ${path}`)
} else {
return
}
}
return await RNFS.mkdir(path)
}
const SplashPage: React.FC<{}> = ({ children }) => {
const [ready, setReady] = useState(false)
const minSplashTime = new Promise(resolve => setTimeout(resolve, 1))
const prepare = async () => {
await mkdir(paths.imageCache)
await mkdir(paths.songCache)
await mkdir(paths.songs)
}
const promise = Promise.all([prepare(), minSplashTime])
useEffect(() => {
promise.then(() => {
setReady(true)
})
})
if (!ready) {
return <Text>Loading THE GOOD SHIT...</Text>
}
return <View style={{ flex: 1 }}>{children}</View>
}
export default SplashPage