├── .dockerignore ├── .github └── workflows │ └── deploy.yml ├── .gitignore ├── App.js ├── Dockerfile ├── LICENSE ├── README.md ├── app.config.js ├── app ├── components │ ├── Header.js │ ├── HistoryItem.js │ ├── ImageError.js │ ├── bar │ │ ├── BottomBar.js │ │ ├── SideBar.js │ │ └── TabBar.js │ ├── button │ │ ├── BackButton.js │ │ ├── FavoritedButton.js │ │ ├── IconButton.js │ │ ├── PlayButton.js │ │ ├── RandomButton.js │ │ ├── SlideBar.js │ │ └── SlideControl.js │ ├── lists │ │ ├── CustomFlat.js │ │ ├── CustomScroll.js │ │ ├── HorizontalAlbums.js │ │ ├── HorizontalArtists.js │ │ ├── HorizontalGenres.js │ │ ├── HorizontalLBStat.js │ │ ├── HorizontalList.js │ │ ├── HorizontalPlaylists.js │ │ ├── ListMap.js │ │ ├── RadioList.js │ │ ├── SongItem.js │ │ ├── SongsList.js │ │ └── VerticalPlaylist.js │ ├── options │ │ └── OptionsSongsList.js │ ├── player │ │ ├── BoxDesktopPlayer.js │ │ ├── BoxPlayer.js │ │ ├── FullScreenHorizontalPlayer.js │ │ ├── FullScreenPlayer.js │ │ ├── Lyric.js │ │ └── Player.js │ ├── popup │ │ ├── ErrorPopup.js │ │ ├── InfoPopup.js │ │ └── OptionsPopup.js │ └── settings │ │ ├── ButtonMenu.js │ │ ├── ButtonSwitch.js │ │ ├── ButtonText.js │ │ ├── HomeOrder.js │ │ ├── OptionInput.js │ │ ├── SelectItem.js │ │ └── TableItem.js ├── contexts │ ├── config.js │ ├── settings.js │ ├── song.js │ ├── theme.js │ └── updateApi.js ├── screens │ ├── Album.js │ ├── Artist.js │ ├── ArtistExplorer.js │ ├── Favorited.js │ ├── Genre.js │ ├── Playlist.js │ ├── Settings │ │ ├── AddServer.js │ │ ├── Cache.js │ │ ├── Connect.js │ │ ├── Home.js │ │ ├── Informations.js │ │ ├── Player.js │ │ ├── Playlists.js │ │ ├── Shares.js │ │ └── Theme.js │ ├── ShowAll.native.js │ ├── ShowAll.web.js │ ├── Stacks.js │ ├── UpdateRadio.js │ └── tabs │ │ ├── Home.js │ │ ├── Playlists.js │ │ ├── Search.js │ │ └── Settings.js ├── services │ ├── servicePlayback.js │ ├── serviceWorkerRegistration.android.js │ ├── serviceWorkerRegistration.ios.js │ └── serviceWorkerRegistration.web.js ├── styles │ ├── main.js │ ├── pres.js │ ├── settings.js │ └── size.js └── utils │ ├── alert.js │ ├── api.js │ ├── cache.native.js │ ├── cache.web.js │ ├── lrc.js │ ├── player.native.js │ ├── player.web.js │ ├── theme.js │ ├── tools.js │ ├── useKeyboardIsOpen.android.js │ └── useKeyboardIsOpen.web.js ├── assets ├── foreground-icon.png └── icon.png ├── babel.config.js ├── docker-compose.yml ├── eas.json ├── eslint.config.mjs ├── index.js ├── index.web.js ├── metro.config.js ├── package-lock.json ├── package.json ├── public ├── index.html ├── manifest.json └── pwa │ ├── adaptative-icon.png │ ├── apple-touch-icon-180.png │ └── icon.svg └── workbox-config.js /.dockerignore: -------------------------------------------------------------------------------- 1 | **/node_modules 2 | .expo 3 | .github 4 | web-build -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy web site to Pages 2 | 3 | on: 4 | # Runs on pushes targeting the default branch 5 | push: 6 | branches: ["main"] 7 | 8 | # Allows you to run this workflow manually from the Actions tab 9 | workflow_dispatch: 10 | 11 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 12 | permissions: 13 | contents: read 14 | pages: write 15 | id-token: write 16 | 17 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 18 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 19 | concurrency: 20 | group: "pages" 21 | cancel-in-progress: false 22 | 23 | jobs: 24 | # Build job 25 | build: 26 | runs-on: ubuntu-latest 27 | steps: 28 | - name: Checkout 29 | uses: actions/checkout@v4 30 | - name: Setup Node 31 | uses: actions/setup-node@v4 32 | with: 33 | node-version: "20" 34 | cache: npm 35 | - name: Install dependencies 36 | run: npm ci 37 | - name: Export Project 38 | run: npm run export:web 39 | - name: Upload artifact 40 | uses: actions/upload-pages-artifact@v3 41 | with: 42 | path: ./dist 43 | 44 | # Deployment job 45 | deploy: 46 | environment: 47 | name: github-pages 48 | url: ${{ steps.deployment.outputs.page_url }} 49 | runs-on: ubuntu-latest 50 | needs: build 51 | steps: 52 | - name: Deploy to GitHub Pages 53 | id: deployment 54 | uses: actions/deploy-pages@v4 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files 2 | 3 | # dependencies 4 | node_modules/ 5 | 6 | # Expo 7 | .expo/ 8 | dist/ 9 | web-build/ 10 | 11 | # Native 12 | *.orig.* 13 | *.jks 14 | *.p8 15 | *.p12 16 | *.key 17 | *.mobileprovision 18 | 19 | # Metro 20 | .metro-health-check* 21 | 22 | # debug 23 | npm-debug.* 24 | yarn-debug.* 25 | yarn-error.* 26 | 27 | # macOS 28 | .DS_Store 29 | *.pem 30 | 31 | # local env files 32 | .env*.local 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | 37 | *.aab 38 | *.apk 39 | *.zip 40 | android 41 | ios 42 | android/** 43 | .node_modules 44 | node_modules 45 | dist -------------------------------------------------------------------------------- /App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { StatusBar } from 'react-native'; 3 | import AsyncStorage from '@react-native-async-storage/async-storage'; 4 | import { NavigationContainer } from '@react-navigation/native'; 5 | import { initialWindowMetrics, SafeAreaProvider } from 'react-native-safe-area-context'; 6 | import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; 7 | import * as NavigationBar from 'expo-navigation-bar'; 8 | 9 | import TabBar from '~/components/bar/TabBar'; 10 | import { HomeStack, SearchStack, PlaylistsStack, SettingsStack } from '~/screens/Stacks'; 11 | 12 | import { ConfigContext, SetConfigContext, getConfig } from '~/contexts/config'; 13 | import { getSettings, SettingsContext, SetSettingsContext } from '~/contexts/settings'; 14 | import Player from '~/utils/player'; 15 | import { SongContext, SongDispatchContext, defaultSong, songReducer } from '~/contexts/song'; 16 | import { ThemeContext, getTheme } from '~/contexts/theme'; 17 | import { SetUpdateApiContext, UpdateApiContext } from './app/contexts/updateApi'; 18 | 19 | const Tab = createBottomTabNavigator(); 20 | 21 | global.maxBitRate = 0; 22 | global.streamFormat = 'mp3'; 23 | 24 | const App = () => { 25 | const [config, setConfig] = React.useState({}); 26 | const [settings, setSettings] = React.useState({}); 27 | const [song, dispatch] = React.useReducer(songReducer, defaultSong) 28 | const [theme, setTheme] = React.useState(getTheme()) 29 | const [updateApi, setUpdateApi] = React.useState({ path: '', query: '' }) 30 | Player.useEvent(dispatch) 31 | 32 | React.useEffect(() => { 33 | if (!song.isInit) Player.initPlayer(dispatch) 34 | getConfig() 35 | .then((config) => { 36 | setConfig(config) 37 | }) 38 | getSettings() 39 | .then((settings) => { 40 | setSettings(settings) 41 | }) 42 | }, []) 43 | 44 | React.useEffect(() => { 45 | if (window) window.config = config 46 | }, [config]) 47 | 48 | React.useEffect(() => { 49 | setTheme(getTheme(settings)) 50 | }, [settings.theme, settings.themePlayer]) 51 | 52 | React.useEffect(() => { 53 | NavigationBar.setBackgroundColorAsync(theme.secondaryBack) 54 | }, [theme]) 55 | 56 | React.useEffect(() => { 57 | if (window) window.streamFormat = settings.streamFormat 58 | else global.streamFormat = settings.streamFormat 59 | }, [settings.streamFormat]) 60 | 61 | React.useEffect(() => { 62 | if (window) window.maxBitRate = settings.maxBitRate 63 | else global.maxBitRate = settings.maxBitRate 64 | }, [settings.maxBitRate]) 65 | 66 | const saveSettings = React.useCallback((settings) => { 67 | setSettings(settings) 68 | AsyncStorage.setItem('settings', JSON.stringify(settings)) 69 | .catch((error) => { 70 | console.error('Save settings error:', error) 71 | }) 72 | }, [settings, setSettings]) 73 | 74 | return ( 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | { 88 | return `Castafiore` 89 | } 90 | }} 91 | > 92 | 97 | } 99 | screenOptions={{ 100 | headerShown: false, 101 | navigationBarColor: theme.primaryBack, 102 | tabBarPosition: settings.isDesktop ? 'left' : 'bottom', 103 | tabBarStyle: { 104 | backgroundColor: theme.secondaryBack, 105 | borderTopColor: theme.secondaryBack, 106 | tabBarActiveTintColor: theme.primaryTouch, 107 | } 108 | }} 109 | > 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | ); 127 | } 128 | 129 | export default App; -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # This docker is to build the android app 2 | # 3 | # docker-compose up --build 4 | # docker exec -it bash 5 | 6 | FROM openjdk:11-jdk 7 | 8 | # Install necessary packages and dependencies 9 | RUN apt-get update && apt-get install -y \ 10 | wget \ 11 | unzip \ 12 | curl \ 13 | git \ 14 | && rm -rf /var/lib/apt/lists/* 15 | 16 | # Set environment variables 17 | ENV ANDROID_SDK_ROOT /opt/android-sdk 18 | ENV PATH ${PATH}:${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin:${ANDROID_SDK_ROOT}/platform-tools 19 | 20 | # Download and install Android SDK command-line tools 21 | RUN mkdir -p ${ANDROID_SDK_ROOT}/cmdline-tools \ 22 | && cd ${ANDROID_SDK_ROOT}/cmdline-tools \ 23 | && wget https://dl.google.com/android/repository/commandlinetools-linux-8512546_latest.zip -O commandlinetools.zip \ 24 | && unzip commandlinetools.zip -d ${ANDROID_SDK_ROOT}/cmdline-tools \ 25 | && mv ${ANDROID_SDK_ROOT}/cmdline-tools/cmdline-tools ${ANDROID_SDK_ROOT}/cmdline-tools/latest \ 26 | && rm commandlinetools.zip 27 | 28 | # Install SDK packages 29 | RUN yes | sdkmanager --licenses \ 30 | && sdkmanager "platform-tools" "platforms;android-34" "build-tools;34.0.0" 31 | 32 | RUN curl -fsSL https://deb.nodesource.com/setup_20.x | sh - && apt install nodejs -y 33 | 34 | RUN npm install -g eas-cli 35 | 36 | WORKDIR /app 37 | COPY package.json /app/package.json 38 | COPY package-lock.json /app/package-lock.json 39 | 40 | WORKDIR /install 41 | RUN ln -s /app/package.json /install/package.json 42 | RUN ln -s /app/package-lock.json /install/package-lock.json 43 | RUN npm install 44 | 45 | ENV NODE_PATH=/install/node_modules 46 | 47 | WORKDIR /app -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | Castafiore is a music player that support Subsonic API. It is available on the following platforms: Web (PWA), Android. 28 | 29 | 30 | 31 | 32 | 33 | 34 | ## Support Feature 35 | - Customize Home page 36 | - Offline music 37 | - Song 38 | - Playlist 39 | - Search 40 | - Artist 41 | - Radio 42 | 43 | ## Build locally 44 | ### Web 45 | If you want to build the web version, run the following command: 46 | ```bash 47 | npm i 48 | npm run export:web 49 | ``` 50 | It will generate a folder `web-build` that you can deploy to your server. 51 | 52 | ### Android 53 | If you want to build the .apk, you need to install Android Studio and you run the following command 54 | ```bash 55 | npm i 56 | npm run export:android 57 | ``` 58 | 59 | ## Development 60 | ### Web 61 | If you want to run the app in development mode, run the following command: 62 | ```bash 63 | npm i 64 | npm run web 65 | ``` 66 | 67 | ### Android 68 | If you want to run the app in development mode for android, you need to install Android Studio. 69 | 70 | Run the following command that will created an apk 71 | ```bash 72 | npm i 73 | npm run build-dev 74 | ``` 75 | 76 | Install the apk and run the dev server 77 | ```bash 78 | npm run android 79 | ``` 80 | -------------------------------------------------------------------------------- /app.config.js: -------------------------------------------------------------------------------- 1 | module.exports = ({ config }) => { 2 | return { 3 | expo: { 4 | name: "Castafiore", 5 | slug: "Castafiore", 6 | description: "Castafiore is a music player that support Navidrome and Subsonic API.", 7 | version: config.version, 8 | orientation: "default", 9 | icon: "./assets/icon.png", 10 | userInterfaceStyle: "light", 11 | assetBundlePatterns: [ 12 | "**/*" 13 | ], 14 | ios: { 15 | supportsTablet: true, 16 | infoPlist: { 17 | UIBackgroundModes: [ 18 | "audio" 19 | ] 20 | }, 21 | bundleIdentifier: "com.sawyerf.castafiore" 22 | }, 23 | android: { 24 | package: "com.sawyerf.castafiore", 25 | adaptiveIcon: { 26 | foregroundImage: "./assets/foreground-icon.png", 27 | backgroundColor: "#660000" 28 | }, 29 | splash: { 30 | image: "./assets/foreground-icon.png", 31 | resizeMode: "contain", 32 | backgroundColor: "#660000" 33 | } 34 | }, 35 | web: { 36 | favicon: "./assets/icon.png", 37 | shortName: "Castafiore", 38 | startUrl: "./index.html", 39 | backgroundColor: "#121212", 40 | theme_color: "#121212" 41 | }, 42 | extra: { 43 | eas: { 44 | projectId: "98d27f72-714e-415c-99f9-30f3f78d68e2" 45 | } 46 | }, 47 | experiments: { 48 | baseUrl: process.env.PLATFORM === "web" ? "./" : undefined 49 | }, 50 | plugins: [ 51 | [ 52 | "expo-build-properties", 53 | { 54 | android: { 55 | usesCleartextTraffic: true 56 | } 57 | } 58 | ] 59 | ] 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /app/components/Header.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { View, Text, StyleSheet } from 'react-native' 3 | import { useNavigation } from '@react-navigation/native' 4 | 5 | import { ThemeContext } from '~/contexts/theme' 6 | import IconButton from '~/components/button/IconButton' 7 | import size from '~/styles/size'; 8 | 9 | const Header = ({ title }) => { 10 | const navigation = useNavigation() 11 | const theme = React.useContext(ThemeContext) 12 | 13 | return ( 14 | 15 | 16 | {title} 17 | 18 | navigation.goBack()} 24 | /> 25 | 26 | ) 27 | } 28 | 29 | const styles = StyleSheet.create({ 30 | header: { 31 | width: '100%', 32 | flexDirection: 'row', 33 | alignItems: 'center', 34 | height: 70 35 | }, 36 | title: theme => ({ 37 | color: theme.primaryText, 38 | fontSize: size.text.large, 39 | fontWeight: 'bold', 40 | flex: 1, 41 | textAlign: 'center', 42 | }), 43 | backButton: { 44 | position: 'absolute', 45 | left: 0, 46 | top: 0, 47 | paddingHorizontal: 20, 48 | height: 70, 49 | } 50 | }) 51 | 52 | export default Header; -------------------------------------------------------------------------------- /app/components/HistoryItem.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Text, View, Pressable } from 'react-native'; 3 | import { useNavigation } from '@react-navigation/native'; 4 | import Icon from 'react-native-vector-icons/FontAwesome'; 5 | 6 | import { ConfigContext } from '~/contexts/config'; 7 | import { playSong } from '~/utils/player'; 8 | import { SongDispatchContext } from '~/contexts/song'; 9 | import { ThemeContext } from '~/contexts/theme'; 10 | import { urlCover } from '~/utils/api'; 11 | import IconButton from '~/components/button/IconButton'; 12 | import ImageError from '~/components/ImageError'; 13 | import mainStyles from '~/styles/main'; 14 | import size from '~/styles/size'; 15 | 16 | const HistoryItem = ({ itemHist, index, setQuery, delItemHistory }) => { 17 | const config = React.useContext(ConfigContext) 18 | const songDispatch = React.useContext(SongDispatchContext) 19 | const theme = React.useContext(ThemeContext) 20 | const navigation = useNavigation() 21 | 22 | const handlePress = () => { 23 | if (typeof itemHist === 'string') { 24 | setQuery(itemHist) 25 | } else if (itemHist.mediaType === 'song') { 26 | playSong(config, songDispatch, [itemHist], 0) 27 | } else if (itemHist.mediaType === 'album') { 28 | navigation.navigate('Album', itemHist) 29 | } else if (itemHist.artistImageUrl) { 30 | navigation.navigate('Artist', itemHist) 31 | } 32 | } 33 | 34 | return ( 35 | ([mainStyles.opacity({ pressed }), { 36 | marginHorizontal: 20, 37 | flexDirection: 'row', 38 | alignItems: 'center', 39 | paddingVertical: typeof itemHist === 'object' ? 0 : 6, 40 | }])}> 41 | { 42 | typeof itemHist === 'object' ? ( 43 | <> 44 | 54 | 55 | {itemHist.name || itemHist.title} 56 | {itemHist.mediaType || 'artist'} · {itemHist.artist} 57 | 58 | > 59 | ) : ( 60 | <> 61 | 62 | {itemHist} 63 | > 64 | ) 65 | } 66 | delItemHistory(index)} 72 | /> 73 | 74 | ) 75 | } 76 | 77 | export default HistoryItem -------------------------------------------------------------------------------- /app/components/ImageError.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { View, Image } from 'react-native' 3 | import Icon from 'react-native-vector-icons/FontAwesome' 4 | 5 | import size from '~/styles/size' 6 | import { ThemeContext } from '~/contexts/theme' 7 | 8 | const ImageError = ({ source, style = {}, children = null, iconError = null }) => { 9 | const [isImage, setIsImage] = React.useState(true) 10 | const [lastSource, setLastSource] = React.useState({ uri: null }) 11 | const theme = React.useContext(ThemeContext) 12 | const ImageMemo = React.useMemo(() => { 13 | if (!lastSource?.uri) { 14 | setIsImage(false) 15 | return null 16 | } 17 | return setIsImage(false)} style={style} /> 18 | }, [lastSource?.uri, style]) 19 | 20 | React.useEffect(() => { 21 | if (lastSource.uri === source?.uri) return 22 | setLastSource(source) 23 | if (!source?.uri) setIsImage(false) 24 | else setIsImage(true) 25 | }, [source, source?.uri]) 26 | 27 | if (isImage) return ImageMemo 28 | if (children) return children 29 | if (iconError) return ( 30 | 31 | 32 | 33 | ) 34 | return ( 35 | 39 | ) 40 | } 41 | 42 | export default ImageError; -------------------------------------------------------------------------------- /app/components/bar/BottomBar.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Text, View, Pressable, Platform } from 'react-native' 3 | import Icon from 'react-native-vector-icons/FontAwesome' 4 | import { useSafeAreaInsets } from 'react-native-safe-area-context' 5 | 6 | import { ConfigContext } from '~/contexts/config' 7 | import { ThemeContext } from '~/contexts/theme' 8 | import mainStyles from '~/styles/main' 9 | import size from '~/styles/size' 10 | import useKeyboardIsOpen from '~/utils/useKeyboardIsOpen' 11 | 12 | const BottomBar = ({ state, descriptors, navigation }) => { 13 | const insets = useSafeAreaInsets() 14 | const config = React.useContext(ConfigContext) 15 | const theme = React.useContext(ThemeContext) 16 | const keyboardIsOpen = useKeyboardIsOpen() 17 | 18 | return ( 19 | 29 | {state.routes.map((route, index) => { 30 | const options = React.useMemo(() => descriptors[route.key].options, []) 31 | const isFocused = React.useMemo(() => state.index === index, [state.index, index]) 32 | const color = React.useMemo(() => { 33 | if (isFocused) return theme.primaryTouch 34 | if (!config.query && route.name !== 'Settings') return theme.secondaryText 35 | return theme.primaryText 36 | }, [isFocused, config.query, route.name, theme]) 37 | 38 | const onPress = () => { 39 | const event = navigation.emit({ 40 | type: 'tabPress', 41 | target: route.key, 42 | canPreventDefault: true, 43 | }) 44 | 45 | if (!isFocused && !event.defaultPrevented) { 46 | navigation.navigate(route.name, route.params) 47 | } 48 | } 49 | 50 | const onLongPress = () => { 51 | navigation.emit({ 52 | type: 'tabLongPress', 53 | target: route.key, 54 | }) 55 | } 56 | 57 | return ( 58 | ([mainStyles.opacity({ pressed }), { 63 | flex: 1, 64 | paddingBottom: insets.bottom ? insets.bottom : Platform.select({ default: 13, android: 10 }), 65 | paddingTop: 10, 66 | }])} 67 | disabled={(!config.query && route.name !== 'Settings')} 68 | > 69 | 70 | 71 | {options.title} 72 | 73 | 74 | ) 75 | })} 76 | 77 | ) 78 | } 79 | 80 | 81 | export default BottomBar -------------------------------------------------------------------------------- /app/components/bar/TabBar.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { ConfigContext } from '~/contexts/config'; 4 | import { SettingsContext } from '~/contexts/settings'; 5 | import Player from '~/components/player/Player'; 6 | import BottomBar from '~/components/bar/BottomBar'; 7 | import SideBar from '~/components/bar/SideBar'; 8 | 9 | const TabBar = ({ state, descriptors, navigation }) => { 10 | const config = React.useContext(ConfigContext) 11 | const settings = React.useContext(SettingsContext) 12 | 13 | React.useEffect(() => { 14 | if (config.query === null) { 15 | navigation.navigate('SettingsStack') 16 | } 17 | }, [config.query]) 18 | 19 | return ( 20 | <> 21 | { 22 | settings.isDesktop ? 23 | 24 | : 25 | } 26 | 27 | > 28 | ); 29 | } 30 | 31 | export default TabBar; -------------------------------------------------------------------------------- /app/components/button/BackButton.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useNavigation } from '@react-navigation/native'; 3 | import { useSafeAreaInsets } from 'react-native-safe-area-context'; 4 | 5 | import IconButton from '~/components/button/IconButton'; 6 | import size from '~/styles/size'; 7 | 8 | const BackButton = () => { 9 | const navigation = useNavigation(); 10 | const insets = useSafeAreaInsets(); 11 | 12 | return ( 13 | navigation.goBack()} 21 | icon="chevron-left" 22 | size={size.icon.small} 23 | color="#fff" 24 | /> 25 | ); 26 | } 27 | 28 | export default BackButton; -------------------------------------------------------------------------------- /app/components/button/FavoritedButton.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { SetUpdateApiContext } from '~/contexts/updateApi'; 4 | import { ConfigContext } from '~/contexts/config'; 5 | import { ThemeContext } from '~/contexts/theme'; 6 | import { getApi, refreshApi } from '~/utils/api'; 7 | import IconButton from '~/components/button/IconButton'; 8 | 9 | const FavoritedButton = ({ id, isFavorited = false, style = {}, size = 23 }) => { 10 | const [favorited, setFavorited] = React.useState(isFavorited) 11 | const theme = React.useContext(ThemeContext) 12 | const config = React.useContext(ConfigContext) 13 | const setUpdateApi = React.useContext(SetUpdateApiContext) 14 | 15 | React.useEffect(() => { 16 | setFavorited(isFavorited) 17 | }, [id, isFavorited]) 18 | 19 | const onPressFavorited = () => { 20 | getApi(config, favorited ? 'unstar' : 'star', `id=${id}`) 21 | .then(() => { 22 | setFavorited(!favorited) 23 | refreshApi(config, 'getStarred', null) 24 | .then(() => { 25 | setUpdateApi({ path: 'getStarred', query: null, uid: 1 }) 26 | }) 27 | }) 28 | .catch((e) => console.error(`FavoritedButton: ${e}`)) 29 | } 30 | 31 | return ( 32 | 39 | ) 40 | } 41 | 42 | export default FavoritedButton; -------------------------------------------------------------------------------- /app/components/button/IconButton.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Pressable, StyleSheet } from 'react-native'; 3 | import Icon from 'react-native-vector-icons/FontAwesome'; 4 | 5 | import { ThemeContext } from '~/contexts/theme'; 6 | import mainStyles from '~/styles/main'; 7 | 8 | const IconButton = ({ icon, size = 23, color = undefined, style = {}, onPress, onLongPress = null, delayLongPress = 200 }) => { 9 | const theme = React.useContext(ThemeContext) 10 | return ( 11 | ([mainStyles.opacity({ pressed }), { justifyContent: 'center' }, StyleSheet.flatten(style) ])} 13 | onLongPress={onLongPress} 14 | delayLongPress={delayLongPress} 15 | onPress={onPress}> 16 | 17 | 18 | ) 19 | } 20 | 21 | export default IconButton; -------------------------------------------------------------------------------- /app/components/button/PlayButton.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { ActivityIndicator } from 'react-native'; 3 | 4 | import { SongContext } from '~/contexts/song'; 5 | import Player from '~/utils/player'; 6 | import IconButton from '~/components/button/IconButton'; 7 | 8 | const PlayButton = ({ style = {}, size, color }) => { 9 | const song = React.useContext(SongContext) 10 | const [icon, setIcon] = React.useState('pause') 11 | const timeout = React.useRef(null) 12 | 13 | React.useEffect(() => { 14 | clearTimeout(timeout.current) 15 | 16 | switch (song.state) { 17 | case Player.State.Playing: 18 | setIcon('pause') 19 | break 20 | case Player.State.Paused: 21 | setIcon('play') 22 | break 23 | case Player.State.Stopped: 24 | setIcon('play') 25 | break 26 | case Player.State.Error: 27 | setIcon('warning') 28 | break 29 | case Player.State.Loading: 30 | timeout.current = setTimeout(() => { 31 | setIcon('loading') 32 | }, 500) 33 | break 34 | default: 35 | break 36 | } 37 | }, [song.state]) 38 | 39 | const onPress = () => { 40 | if (song.state === Player.State.Error) { 41 | Player.reload() 42 | } if (song.state === Player.State.Playing) { 43 | Player.pauseSong() 44 | } else { 45 | Player.resumeSong() 46 | } 47 | } 48 | 49 | if (icon === 'loading') { 50 | return 51 | } 52 | return ( 53 | 60 | ) 61 | } 62 | 63 | export default PlayButton; -------------------------------------------------------------------------------- /app/components/button/RandomButton.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { ConfigContext } from '~/contexts/config'; 4 | import { playSong } from '~/utils/player'; 5 | import { SongDispatchContext } from '~/contexts/song'; 6 | import { ThemeContext } from '~/contexts/theme'; 7 | import IconButton from '~/components/button/IconButton'; 8 | import presStyles from '~/styles/pres'; 9 | 10 | const RandomButton = ({ songList, size = 23 }) => { 11 | const theme = React.useContext(ThemeContext) 12 | const songDispatch = React.useContext(SongDispatchContext) 13 | const config = React.useContext(ConfigContext) 14 | 15 | const shuffle = (array) => { 16 | return array.map(value => ({ value, sort: Math.random() })) 17 | .sort((a, b) => a.sort - b.sort) 18 | .map(({ value }) => value) 19 | } 20 | 21 | const shuffleSong = () => { 22 | if (songList?.length) { 23 | playSong(config, songDispatch, shuffle(songList), 0) 24 | } 25 | } 26 | 27 | return ( 28 | 35 | ); 36 | } 37 | 38 | export default RandomButton; -------------------------------------------------------------------------------- /app/components/button/SlideBar.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { View, Platform, PanResponder, StyleSheet, Dimensions } from 'react-native' 3 | 4 | import { ThemeContext } from '~/contexts/theme' 5 | 6 | const SlideBar = ({ 7 | progress = 0, 8 | onStart = () => { }, 9 | onChange = () => { }, 10 | onComplete = () => { }, 11 | stylePress = {}, 12 | styleBar = {}, 13 | styleProgress = {}, 14 | isBitogno = false, 15 | sizeBitogno = 12, 16 | }) => { 17 | const theme = React.useContext(ThemeContext) 18 | const layoutBar = React.useRef({ width: 0, height: 0, x: 0 }) 19 | const viewRef = React.useRef(null) 20 | const prog = React.useRef(0) 21 | const panResponder = PanResponder.create({ 22 | onStartShouldSetPanResponder: () => true, 23 | onMoveShouldSetPanResponder: () => true, 24 | onPanResponderGrant: (_, gestureState) => { 25 | prog.current = (gestureState.x0 - layoutBar.current.x) / layoutBar.current.width 26 | if (!prog.current || prog.current < 0) return onStart(0) 27 | else if (prog.current > 1) return onStart(1) 28 | onStart(prog.current) 29 | }, 30 | onPanResponderMove: (_, gestureState) => { 31 | prog.current = (gestureState.moveX - layoutBar.current.x) / layoutBar.current.width 32 | if (!prog.current || prog.current < 0) return onChange(0) 33 | else if (prog.current > 1) return onChange(1) 34 | onChange(prog.current) 35 | }, 36 | onPanResponderRelease: () => { 37 | if (!prog.current || prog.current < 0) return onComplete(0) 38 | else if (prog.current > 1) return onComplete(1) 39 | onComplete(prog.current) 40 | } 41 | }) 42 | 43 | const upLayoutBar = React.useCallback(() => { 44 | if (!viewRef.current) return 45 | viewRef.current.measure((x, y, width, height, pageX) => { 46 | layoutBar.current = { width, x: pageX } 47 | }) 48 | }, []) 49 | 50 | React.useEffect(() => { 51 | const sub = Dimensions.addEventListener('change', upLayoutBar) 52 | return () => sub.remove() 53 | }, []) 54 | 55 | return ( 56 | 63 | 64 | 67 | 68 | {isBitogno && } 69 | 70 | ) 71 | } 72 | 73 | const styles = StyleSheet.create({ 74 | bitognoBar: (vol, sizeBitogno, theme) => ({ 75 | position: 'absolute', 76 | width: sizeBitogno, 77 | height: sizeBitogno, 78 | borderRadius: sizeBitogno / 2, 79 | backgroundColor: theme.primaryTouch, 80 | left: Platform.select({ web: `calc(${vol * 100}% - ${sizeBitogno / 2}px)`, default: vol * 99 + '%' }), 81 | top: 7 82 | }) 83 | }) 84 | 85 | export default SlideBar; -------------------------------------------------------------------------------- /app/components/button/SlideControl.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Animated, PanResponder } from 'react-native'; 3 | 4 | import { ConfigContext } from '~/contexts/config'; 5 | import { SongContext, SongDispatchContext } from '~/contexts/song'; 6 | import Player from '~/utils/player'; 7 | 8 | const SlideControl = ({ children, style, }) => { 9 | const song = React.useContext(SongContext) 10 | const songDispatch = React.useContext(SongDispatchContext) 11 | const config = React.useContext(ConfigContext) 12 | const startMove = React.useRef(0) 13 | const position = React.useRef(new Animated.Value(0)).current 14 | const previousTap = React.useRef(0) 15 | const panResponder = PanResponder.create({ 16 | onStartShouldSetPanResponder: () => true, 17 | onMoveShouldSetPanResponder: () => true, 18 | onPanResponderGrant: (_, gestureState) => { 19 | startMove.current = gestureState.x0 20 | position.setValue(0) 21 | }, 22 | onPanResponderMove: (_, gestureState) => { 23 | const move = gestureState.moveX - startMove.current 24 | if (move < -100) position.setValue(-100) 25 | else if (move > 100) position.setValue(100) 26 | else position.setValue(move) 27 | }, 28 | onPanResponderRelease: () => { 29 | startMove.current = 0 30 | if (position._value < -50) { 31 | Player.nextSong(config, song, songDispatch) 32 | } else if (position._value > 50) { 33 | Player.previousSong(config, song, songDispatch) 34 | } else if (position._value === 0) { 35 | let now = Date.now() 36 | if (now - previousTap.current < 300) { 37 | if (song.state === Player.State.Playing) Player.pauseSong() 38 | else Player.resumeSong() 39 | now = 0 40 | } 41 | previousTap.current = now 42 | } 43 | Animated.timing(position, { 44 | toValue: 0, 45 | useNativeDriver: true, 46 | duration: 200 47 | }).start() 48 | } 49 | }) 50 | 51 | return ( 52 | 59 | {children} 60 | 61 | ) 62 | } 63 | 64 | export default SlideControl; -------------------------------------------------------------------------------- /app/components/lists/CustomFlat.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { View, FlatList, StyleSheet } from 'react-native'; 3 | 4 | import { SettingsContext } from '~/contexts/settings'; 5 | import { ThemeContext } from '~/contexts/theme'; 6 | import IconButton from '~/components/button/IconButton'; 7 | import size from '~/styles/size'; 8 | 9 | const CustomScroll = ({ data, renderItem, style = { width: '100%' }, contentContainerStyle = { paddingHorizontal: 20, columnGap: 10 } }) => { 10 | const theme = React.useContext(ThemeContext) 11 | const settings = React.useContext(SettingsContext) 12 | const indexScroll = React.useRef(0) 13 | const refScroll = React.useRef(null) 14 | 15 | const goRight = () => { 16 | if (indexScroll.current + 3 >= data.length) indexScroll.current = data.length - 1 17 | else indexScroll.current = indexScroll.current + 3 18 | refScroll.current.scrollToIndex({ index: indexScroll.current, animated: true, viewOffset: 20 }) 19 | } 20 | 21 | const goLeft = () => { 22 | if (indexScroll.current < 3) indexScroll.current = 0 23 | else indexScroll.current = indexScroll.current - 3 24 | refScroll.current.scrollToIndex({ index: indexScroll.current, animated: true, viewOffset: 20 }) 25 | } 26 | 27 | // View is necessary to show the scroll helper 28 | return ( 29 | 30 | {settings?.scrollHelper && 31 | 32 | 33 | 34 | } 35 | `${item.id}-${index}`} 39 | renderItem={renderItem} 40 | horizontal={true} 41 | style={style} 42 | onScrollToIndexFailed={() => { }} 43 | contentContainerStyle={contentContainerStyle} 44 | showsHorizontalScrollIndicator={false} 45 | /> 46 | 47 | ) 48 | } 49 | 50 | const styles = StyleSheet.create({ 51 | scrollContainer: { 52 | position: 'absolute', 53 | zIndex: 1, 54 | flexDirection: 'row', 55 | right: 10, 56 | top: -40, 57 | columnGap: 1, 58 | }, 59 | scrollHelper: theme => ({ 60 | backgroundColor: theme.secondaryBack, 61 | height: 30, 62 | width: 30, 63 | borderTopLeftRadius: 5, 64 | borderBottomLeftRadius: 5, 65 | justifyContent: 'center', 66 | alignItems: 'center', 67 | }), 68 | }) 69 | 70 | export default CustomScroll; -------------------------------------------------------------------------------- /app/components/lists/CustomScroll.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { View, ScrollView, StyleSheet } from 'react-native'; 3 | 4 | import IconButton from '~/components/button/IconButton'; 5 | import { ThemeContext } from '~/contexts/theme'; 6 | import { SettingsContext } from '~/contexts/settings'; 7 | import size from '~/styles/size'; 8 | 9 | const CustomScroll = ({ children, data, renderItem, style = { width: '100%' }, contentContainerStyle = { paddingHorizontal: 20, columnGap: 10 } }) => { 10 | const theme = React.useContext(ThemeContext) 11 | const settings = React.useContext(SettingsContext) 12 | const indexScroll = React.useRef(0) 13 | const refScroll = React.useRef(null) 14 | 15 | const goRight = () => { 16 | if (indexScroll.current + 3 >= data.length) indexScroll.current = data.length - 1 17 | else indexScroll.current = indexScroll.current + 30 18 | refScroll.current.scrollTo({ x: indexScroll.current, y: 0, animated: true, viewOffset: 10 }) 19 | } 20 | 21 | const goLeft = () => { 22 | if (indexScroll.current < 3) indexScroll.current = 0 23 | else indexScroll.current = indexScroll.current - 30 24 | refScroll.current.scrollTo({ x: indexScroll.current, y: 0, animated: true, viewOffset: 10 }) 25 | } 26 | 27 | // View is necessary to show the scroll helper 28 | return ( 29 | 30 | {settings?.scrollHelper && 31 | 32 | 33 | 34 | } 35 | index} 38 | renderItem={renderItem} 39 | horizontal={true} 40 | style={style} 41 | contentContainerStyle={contentContainerStyle} 42 | showsHorizontalScrollIndicator={false} 43 | ref={refScroll} 44 | > 45 | {children} 46 | 47 | 48 | ) 49 | } 50 | 51 | const styles = StyleSheet.create({ 52 | scrollContainer: { 53 | position: 'absolute', 54 | zIndex: 1, 55 | flexDirection: 'row', 56 | right: 10, 57 | top: -40, 58 | columnGap: 1, 59 | }, 60 | scrollHelper: theme => ({ 61 | backgroundColor: theme.secondaryBack, 62 | height: 30, 63 | width: 30, 64 | borderTopLeftRadius: 5, 65 | borderBottomLeftRadius: 5, 66 | justifyContent: 'center', 67 | alignItems: 'center', 68 | }), 69 | }) 70 | 71 | export default CustomScroll; -------------------------------------------------------------------------------- /app/components/lists/HorizontalAlbums.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Text, Image, StyleSheet, Pressable, Platform, Share } from 'react-native'; 3 | import { useNavigation } from '@react-navigation/native'; 4 | 5 | import { ThemeContext } from '~/contexts/theme'; 6 | import { urlCover, getApi } from '~/utils/api'; 7 | import { ConfigContext } from '~/contexts/config'; 8 | import OptionsPopup from '~/components/popup/OptionsPopup'; 9 | import CustomFlat from '~/components/lists/CustomFlat'; 10 | import size from '~/styles/size'; 11 | import mainStyles from '~/styles/main'; 12 | 13 | const HorizontalAlbums = ({ albums, year = false, onPress = () => { } }) => { 14 | const navigation = useNavigation(); 15 | const config = React.useContext(ConfigContext) 16 | const theme = React.useContext(ThemeContext) 17 | const refOption = React.useRef() 18 | const [indexOptions, setIndexOptions] = React.useState(-1) 19 | 20 | return ( 21 | <> 22 | ( 25 | ([mainStyles.opacity({ pressed }), styles.album])} 27 | onLongPress={() => setIndexOptions(index)} 28 | delayLongPress={200} 29 | onPress={() => { 30 | onPress(item) 31 | navigation.navigate('Album', item) 32 | }}> 33 | 39 | {item.name} 40 | {year ? item.year : item.artist} 41 | 42 | )} /> 43 | 44 | = 0} 47 | close={() => { setIndexOptions(-1) }} 48 | item={indexOptions >= 0 ? albums[indexOptions] : null} 49 | options={[ 50 | { 51 | name: 'Go to artist', 52 | icon: 'user', 53 | onPress: () => { 54 | refOption.current.close() 55 | navigation.navigate('Artist', { id: albums[indexOptions].artistId, name: albums[indexOptions].artist }) 56 | } 57 | }, 58 | { 59 | name: 'Share', 60 | icon: 'share', 61 | onPress: () => { 62 | getApi(config, 'createShare', { id: albums[indexOptions].id }) 63 | .then((json) => { 64 | if (json.shares.share.length > 0) { 65 | if (Platform.OS === 'web') navigator.clipboard.writeText(json.shares.share[0].url) 66 | else Share.share({ message: json.shares.share[0].url }) 67 | } 68 | }) 69 | .catch(() => { }) 70 | refOption.current.close() 71 | } 72 | }, 73 | { 74 | name: 'Info', 75 | icon: 'info', 76 | onPress: () => { 77 | refOption.current.setInfo(albums[indexOptions]) 78 | setIndexOptions(-1) 79 | } 80 | } 81 | ]} 82 | /> 83 | > 84 | ) 85 | } 86 | 87 | const styles = StyleSheet.create({ 88 | album: { 89 | width: size.image.large, 90 | height: 210, 91 | alignItems: 'center', 92 | }, 93 | albumCover: { 94 | width: size.image.large, 95 | height: size.image.large, 96 | marginBottom: 6, 97 | }, 98 | titleAlbum: (theme) => ({ 99 | color: theme.primaryText, 100 | fontSize: size.text.small, 101 | width: size.image.large, 102 | marginBottom: 3, 103 | marginTop: 3, 104 | }), 105 | artist: theme => ({ 106 | color: theme.secondaryText, 107 | fontSize: size.text.small, 108 | width: size.image.large, 109 | }), 110 | }) 111 | 112 | export default HorizontalAlbums; -------------------------------------------------------------------------------- /app/components/lists/HorizontalArtists.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Text, StyleSheet, Pressable } from 'react-native'; 3 | import { useNavigation } from '@react-navigation/native'; 4 | 5 | import { ThemeContext } from '~/contexts/theme'; 6 | import { ConfigContext } from '~/contexts/config'; 7 | import { urlCover } from '~/utils/api'; 8 | import ImageError from '~/components/ImageError'; 9 | import CustomFlat from '~/components/lists/CustomFlat'; 10 | import OptionsPopup from '~/components/popup/OptionsPopup'; 11 | import size from '~/styles/size'; 12 | import mainStyles from '~/styles/main'; 13 | 14 | const HorizontalArtists = ({ artists, onPress = () => { } }) => { 15 | const navigation = useNavigation(); 16 | const theme = React.useContext(ThemeContext) 17 | const config = React.useContext(ConfigContext) 18 | const refOption = React.useRef() 19 | const [indexOptions, setIndexOptions] = React.useState(-1) 20 | 21 | return ( 22 | <> 23 | ( 26 | ([mainStyles.opacity({ pressed }), styles.artist])} 28 | onPress={() => { 29 | onPress(item) 30 | navigation.navigate('Artist', { id: item.id, name: item.name }) 31 | }} 32 | onLongPress={() => setIndexOptions(index)} 33 | delayLongPress={200} 34 | > 35 | 40 | {item.name} 41 | 42 | )} 43 | /> 44 | 45 | = 0} 48 | close={() => { setIndexOptions(-1) }} 49 | item={indexOptions >= 0 ? artists[indexOptions] : null} 50 | options={[ 51 | { 52 | name: 'Info', 53 | icon: 'info', 54 | onPress: () => { 55 | refOption.current.setInfo(artists[indexOptions]) 56 | setIndexOptions(-1) 57 | } 58 | } 59 | ]} 60 | /> 61 | > 62 | ) 63 | } 64 | 65 | const styles = StyleSheet.create({ 66 | artist: { 67 | flexDirection: 'collumn', 68 | alignItems: 'center', 69 | }, 70 | artistCover: { 71 | height: size.image.medium, 72 | width: size.image.medium, 73 | marginBottom: 10, 74 | borderRadius: size.radius.circle, 75 | }, 76 | }) 77 | 78 | export default HorizontalArtists; -------------------------------------------------------------------------------- /app/components/lists/HorizontalGenres.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Text, StyleSheet, Pressable } from 'react-native'; 3 | import { useNavigation } from '@react-navigation/native'; 4 | 5 | import { ThemeContext } from '~/contexts/theme'; 6 | import CustomFlat from '~/components/lists/CustomFlat'; 7 | import size from '~/styles/size'; 8 | import mainStyles from '~/styles/main'; 9 | 10 | const HorizontalGenres = ({ genres }) => { 11 | const navigation = useNavigation(); 12 | const theme = React.useContext(ThemeContext) 13 | 14 | return ( 15 | ( 20 | ([mainStyles.opacity({ pressed }), styles.genreBox(theme)])} 22 | onPress={() => navigation.navigate('Genre', { genre: item })}> 23 | {item.value} 24 | 25 | )} 26 | /> 27 | ) 28 | } 29 | 30 | const styles = StyleSheet.create({ 31 | genreList: { 32 | width: '100%', 33 | }, 34 | scrollContainer: { 35 | height: 55 * 2 + 10, 36 | paddingStart: 20, 37 | paddingEnd: 20, 38 | flexDirection: 'column', 39 | flexWrap: 'wrap', 40 | columnGap: 10, 41 | rowGap: 10, 42 | }, 43 | genreBox: theme => ({ 44 | flex: 1, 45 | height: 55, 46 | borderRadius: 3, 47 | paddingHorizontal: 40, 48 | backgroundColor: theme.primaryTouch, 49 | justifyContent: 'center', 50 | alignItems: 'center', 51 | }), 52 | genreText: theme => ({ 53 | color: theme.primaryText, 54 | fontSize: size.text.large, 55 | fontWeight: 'bold', 56 | }), 57 | }) 58 | 59 | export default HorizontalGenres; -------------------------------------------------------------------------------- /app/components/lists/HorizontalLBStat.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Text, View, StyleSheet } from 'react-native'; 3 | 4 | import { ThemeContext } from '~/contexts/theme'; 5 | import size from '~/styles/size'; 6 | 7 | const HorizontalLBStat = ({ stats }) => { 8 | const theme = React.useContext(ThemeContext) 9 | const [maxCount, setMaxCount] = React.useState(0) 10 | 11 | React.useEffect(() => { 12 | if (typeof stats === 'string') return 13 | let maxCount = 1; 14 | stats?.forEach((stat) => { 15 | if (stat.listen_count > maxCount) maxCount = stat.listen_count 16 | }) 17 | setMaxCount(maxCount) 18 | }, [stats]) 19 | 20 | if (typeof stats === 'string') return ( 21 | {stats} 27 | ) 28 | return ( 29 | 30 | { 31 | stats.map((item, index) => { 32 | const time = new Date(item.time_range) 33 | 34 | return ( 35 | 42 | {time.getDate()} 43 | 50 | {item.listen_count} 51 | 52 | ) 53 | }) 54 | } 55 | 56 | ) 57 | } 58 | 59 | const styles = StyleSheet.create({ 60 | custScroll: { 61 | width: '100%', 62 | }, 63 | scrollContainer: length => ({ 64 | display: 'flex', 65 | width: '100%', 66 | maxWidth: length * 60, 67 | paddingStart: 20, 68 | paddingEnd: 20, 69 | flexDirection: 'row', 70 | columnGap: 10, 71 | rowGap: 10, 72 | }), 73 | }) 74 | 75 | export default HorizontalLBStat; -------------------------------------------------------------------------------- /app/components/lists/HorizontalList.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Pressable, Text } from 'react-native'; 3 | import { useNavigation } from '@react-navigation/native'; 4 | import Icon from 'react-native-vector-icons/FontAwesome' 5 | 6 | import { getCachedAndApi, getUrl } from '~/utils/api'; 7 | import { getJsonCache } from '~/utils/cache'; 8 | import { ThemeContext } from '~/contexts/theme'; 9 | import { SettingsContext, getPathByType, setListByType } from '~/contexts/settings'; 10 | import { ConfigContext } from '~/contexts/config'; 11 | import { UpdateApiContext, isUpdatable } from '~/contexts/updateApi'; 12 | import HorizontalAlbums from '~/components/lists/HorizontalAlbums'; 13 | import HorizontalArtists from '~/components/lists/HorizontalArtists'; 14 | import HorizontalGenres from '~/components/lists/HorizontalGenres'; 15 | import HorizontalLBStat from '~/components/lists/HorizontalLBStat'; 16 | import HorizontalPlaylists from '~/components/lists/HorizontalPlaylists'; 17 | import mainStyles from '~/styles/main'; 18 | import RadioList from '~/components/lists/RadioList'; 19 | import size from '~/styles/size'; 20 | 21 | const HorizontalList = ({ title, type, query, refresh, enable }) => { 22 | const [list, setList] = React.useState(); 23 | const theme = React.useContext(ThemeContext) 24 | const settings = React.useContext(SettingsContext) 25 | const navigation = useNavigation(); 26 | const config = React.useContext(ConfigContext) 27 | const updateApi = React.useContext(UpdateApiContext) 28 | 29 | React.useEffect(() => { 30 | if (!enable) return 31 | if (config.query) { 32 | getList() 33 | } 34 | }, [config, refresh, type, query, enable, settings.listenBrainzUser]) 35 | 36 | React.useEffect(() => { 37 | const path = getPathByType(type) 38 | let nquery = query ? query : '' 39 | 40 | if (type == 'album') nquery += '&size=' + settings.sizeOfList 41 | 42 | if (isUpdatable(updateApi, path, nquery)) { 43 | getJsonCache('api', getUrl(config, path, query)) 44 | .then(json => setListByType(json, type, setList)) 45 | } 46 | }, [updateApi]) 47 | 48 | const getList = async () => { 49 | const path = getPathByType(type) 50 | let nquery = query ? query : '' 51 | 52 | if (type == 'album') nquery += '&size=' + settings.sizeOfList 53 | if (type == 'listenbrainz') { 54 | if (!settings.listenBrainzUser) return setList('No ListenBrainz user set') 55 | fetch(`https://api.listenbrainz.org/1/stats/user/${encodeURIComponent(settings.listenBrainzUser)}/listening-activity?range=this_week`, { mode: 'cors' }) 56 | .then(response => response.json()) 57 | .then(data => { 58 | if (data.error) return ( 59 | setList(data.error) 60 | ) 61 | if (!data?.payload?.listening_activity?.length) return 62 | setList(data.payload.listening_activity) 63 | }) 64 | .catch(error => console.error(error)) 65 | } else { 66 | getCachedAndApi(config, path, nquery, (json) => setListByType(json, type, setList)) 67 | } 68 | } 69 | 70 | const isShowAllType = React.useCallback((type) => { 71 | return ['album', 'artist', 'album_star', 'artist_all'].includes(type) 72 | }, []) 73 | 74 | if (!enable) return null 75 | if (!list) return null 76 | return ( 77 | <> 78 | { navigation.navigate('ShowAll', { title, type, query }) }} 87 | > 88 | {title} 89 | { 90 | isShowAllType(type) && 96 | } 97 | 98 | {type === 'album' && } 99 | {type === 'album_star' && } 100 | {type === 'artist' && } 101 | {type === 'artist_all' && } 102 | {type === 'genre' && } 103 | {type === 'radio' && } 104 | {type === 'listenbrainz' && } 105 | {type === 'playlist' && } 106 | > 107 | ) 108 | } 109 | 110 | export default HorizontalList; -------------------------------------------------------------------------------- /app/components/lists/HorizontalPlaylists.js: -------------------------------------------------------------------------------- 1 | 2 | import React from 'react' 3 | import { Text, View, Pressable } from 'react-native' 4 | import { useNavigation } from '@react-navigation/native' 5 | 6 | import { ConfigContext } from '~/contexts/config' 7 | import { SongDispatchContext } from '~/contexts/song' 8 | import { ThemeContext } from '~/contexts/theme' 9 | import { SettingsContext } from '~/contexts/settings' 10 | import { urlCover, useCachedAndApi } from '~/utils/api' 11 | import FavoritedButton from '~/components/button/FavoritedButton' 12 | import mainStyles from '~/styles/main' 13 | import CustomFlat from '~/components/lists/CustomFlat' 14 | import ImageError from '~/components/ImageError' 15 | import Player from '~/utils/player' 16 | import size from '~/styles/size' 17 | 18 | // TODO: made this component beautiful 19 | const ItemPlaylist = ({ item }) => { 20 | const theme = React.useContext(ThemeContext) 21 | const config = React.useContext(ConfigContext) 22 | const settings = React.useContext(SettingsContext) 23 | const navigation = useNavigation() 24 | const songDispatch = React.useContext(SongDispatchContext) 25 | 26 | const [songList] = useCachedAndApi([], 'getPlaylist', `id=${item.id}`, (json, setData) => { 27 | if (settings.reversePlaylist) setData(json?.playlist?.entry.reverse() || []) 28 | else setData(json?.playlist?.entry || []) 29 | }, [item.id, settings.reversePlaylist]) 30 | 31 | return ( 32 | 33 | ([mainStyles.opacity(pressed), { flexDirection: 'row', alignItems: 'center', width: '100%' }])} 35 | onPress={() => navigation.navigate('Playlist', { playlist: item })} 36 | > 37 | 41 | 42 | 43 | {item.name} 44 | 45 | 46 | {(item.duration / 60) | 1} min · {item.songCount} songs 47 | 48 | 49 | 50 | 51 | { 52 | songList.slice(0, 3).map((item, index) => ( 53 | ([mainStyles.opacity(pressed), { 56 | flexDirection: 'row', 57 | alignItems: 'center', 58 | width: '100%', 59 | paddingEnd: 10, 60 | }])} 61 | onPress={() => Player.playSong(config, songDispatch, songList, index)} 62 | > 63 | 67 | 68 | 69 | {item.title} 70 | 71 | 72 | {item.artist} 73 | 74 | 75 | 76 | 77 | )) 78 | } 79 | 80 | 81 | ) 82 | } 83 | 84 | const HorizontalPlaylists = ({ playlists }) => { 85 | const config = React.useContext(ConfigContext) 86 | 87 | return ( 88 | playlist.comment?.includes(`#${config.username}-pin`))} 90 | renderItem={({ item }) => } 91 | /> 92 | ) 93 | } 94 | 95 | export default HorizontalPlaylists -------------------------------------------------------------------------------- /app/components/lists/ListMap.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const ListMap = ({ data, renderItem, ListEmptyComponent = null }) => { 4 | if (!data || data.length === 0) return ListEmptyComponent 5 | return ( 6 | <> 7 | {data.map(renderItem)} 8 | > 9 | ) 10 | } 11 | 12 | export default ListMap 13 | -------------------------------------------------------------------------------- /app/components/lists/SongItem.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Text, View, StyleSheet, Pressable } from 'react-native'; 3 | import Icon from 'react-native-vector-icons/FontAwesome'; 4 | 5 | import { ConfigContext } from '~/contexts/config'; 6 | import { getCache } from '~/utils/cache'; 7 | import { playSong } from '~/utils/player'; 8 | import { SettingsContext } from '~/contexts/settings'; 9 | import { SongDispatchContext } from '~/contexts/song'; 10 | import { ThemeContext } from '~/contexts/theme'; 11 | import { urlCover, urlStream } from '~/utils/api'; 12 | import FavoritedButton from '~/components/button/FavoritedButton'; 13 | import ImageError from '~/components/ImageError'; 14 | import mainStyles from '~/styles/main'; 15 | import size from '~/styles/size'; 16 | 17 | const Cached = ({ song }) => { 18 | const [isCached, setIsCached] = React.useState(false) 19 | const theme = React.useContext(ThemeContext) 20 | const settings = React.useContext(SettingsContext) 21 | const config = React.useContext(ConfigContext) 22 | 23 | React.useEffect(() => { 24 | cached(song) 25 | .then((res) => { 26 | setIsCached(res) 27 | }) 28 | }, [song.id, settings.showCache]) 29 | 30 | const cached = async (song) => { 31 | if (!settings.showCache) return false 32 | const cache = await getCache('song', urlStream(config, song.id, settings.streamFormat, settings.maxBitrate)) 33 | if (cache) return true 34 | return false 35 | } 36 | 37 | if (isCached) return ( 38 | 44 | ) 45 | return null 46 | } 47 | 48 | const SongItem = ({ song, queue, index, isIndex = false, isPlaying = false, setIndexOptions = () => { }, onPress = () => { }, style={} }) => { 49 | const songDispatch = React.useContext(SongDispatchContext) 50 | const theme = React.useContext(ThemeContext) 51 | const config = React.useContext(ConfigContext) 52 | 53 | return ( 54 | ([mainStyles.opacity({ pressed }), styles.song, style])} 56 | key={song.id} 57 | onLongPress={() => setIndexOptions(index)} 58 | onContextMenu={() => setIndexOptions(index)} 59 | delayLongPress={200} 60 | onPress={() => { 61 | onPress(song) 62 | playSong(config, songDispatch, queue, index) 63 | }}> 64 | 70 | 71 | 72 | {(isIndex && song.track !== undefined) ? `${song.track}. ` : null}{song.title} 73 | 74 | 75 | {song.artist} 76 | 77 | 78 | 79 | 80 | 81 | ) 82 | } 83 | 84 | const styles = StyleSheet.create({ 85 | song: { 86 | flexDirection: 'row', 87 | alignItems: 'center', 88 | marginBottom: 10, 89 | }, 90 | }) 91 | 92 | export default SongItem; -------------------------------------------------------------------------------- /app/components/lists/SongsList.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Text, View } from 'react-native'; 3 | import Icon from 'react-native-vector-icons/FontAwesome'; 4 | 5 | import { ThemeContext } from '~/contexts/theme'; 6 | import SongItem from '~/components/lists/SongItem'; 7 | import size from '~/styles/size'; 8 | import OptionsSongsList from '~/components/options/OptionsSongsList'; 9 | 10 | const SongsList = ({ songs, isIndex = false, listToPlay = null, isMargin = true, indexPlaying = null, idPlaylist = null, onUpdate = () => { }, onPress = () => { } }) => { 11 | const theme = React.useContext(ThemeContext) 12 | const [indexOptions, setIndexOptions] = React.useState(-1) 13 | const isMultiCD = React.useMemo(() => songs?.filter(item => item.discNumber !== songs[0].discNumber).length > 0, [songs]) 14 | 15 | return ( 16 | 20 | {songs?.map((item, index) => { 21 | return ( 22 | 23 | { 24 | isIndex && isMultiCD && (index === 0 || songs[index - 1].discNumber !== item.discNumber) && 25 | 26 | 27 | Disc {item.discNumber} 28 | 29 | } 30 | 39 | 40 | ) 41 | })} 42 | 49 | 50 | ) 51 | } 52 | 53 | export default SongsList; -------------------------------------------------------------------------------- /app/components/options/OptionsSongsList.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Platform, Share } from 'react-native'; 3 | import { useNavigation } from '@react-navigation/native'; 4 | 5 | import { playSong } from '~/utils/player'; 6 | import { getApi, getApiNetworkFirst, urlStream } from '~/utils/api'; 7 | import { ConfigContext } from '~/contexts/config'; 8 | import { SongContext, SongDispatchContext } from '~/contexts/song'; 9 | import OptionsPopup from '~/components/popup/OptionsPopup'; 10 | 11 | const OptionsSongsList = ({ songs, indexOptions, setIndexOptions, onUpdate=() => {}, idPlaylist = null }) => { 12 | const navigation = useNavigation(); 13 | const song = React.useContext(SongContext); 14 | const songDispatch = React.useContext(SongDispatchContext); 15 | const config = React.useContext(ConfigContext); 16 | const [playlistList, setPlaylistList] = React.useState([]); 17 | const reffOption = React.useRef(); 18 | 19 | return ( 20 | = 0} 23 | close={() => { 24 | setPlaylistList([]) 25 | setIndexOptions(-1) 26 | }} 27 | item={indexOptions >= 0 ? songs[indexOptions] : null} 28 | options={[ 29 | { 30 | name: 'Play similar songs', 31 | icon: 'play', 32 | onPress: () => { 33 | getApiNetworkFirst(config, 'getSimilarSongs', `id=${songs[indexOptions].id}&count=50`) 34 | .then((json) => { 35 | if (!json.similarSongs?.song) { 36 | playSong(config, songDispatch, [songs[indexOptions]], 0) 37 | } else { 38 | playSong(config, songDispatch, json.similarSongs?.song, 0) 39 | } 40 | }) 41 | .catch(() => { }) 42 | setIndexOptions(-1) 43 | } 44 | }, 45 | { 46 | name: 'Add to queue', 47 | icon: 'th-list', 48 | onPress: () => { 49 | if (song.queue) { 50 | songDispatch({ type: 'addQueue', queue: [songs[indexOptions]] }) 51 | } else { 52 | playSong(config, songDispatch, [songs[indexOptions]], 0) 53 | } 54 | setIndexOptions(-1) 55 | } 56 | }, 57 | { 58 | name: 'Go to artist', 59 | icon: 'user', 60 | onPress: () => { 61 | navigation.navigate('Artist', { id: songs[indexOptions].artistId, name: songs[indexOptions].artist }) 62 | setIndexOptions(-1) 63 | } 64 | }, 65 | { 66 | name: 'Go to album', 67 | icon: 'folder-open', 68 | onPress: () => { 69 | navigation.navigate('Album', { id: songs[indexOptions].albumId, name: songs[indexOptions].album, artist: songs[indexOptions].artist, artistId: songs[indexOptions].artistId }) 70 | setIndexOptions(-1) 71 | } 72 | }, 73 | { 74 | name: 'Add to playlist', 75 | icon: 'plus', 76 | onPress: () => { 77 | if (playlistList.length > 0) { 78 | setPlaylistList([]) 79 | return 80 | } 81 | getApi(config, 'getPlaylists') 82 | .then((json) => { 83 | setPlaylistList(json.playlists.playlist) 84 | }) 85 | .catch(() => { }) 86 | } 87 | }, 88 | ...(playlistList?.map((playlist) => ({ 89 | name: playlist.name, 90 | icon: 'angle-right', 91 | indent: 1, 92 | onPress: () => { 93 | getApi(config, 'updatePlaylist', `playlistId=${playlist.id}&songIdToAdd=${songs[indexOptions].id}`) 94 | .then(() => { 95 | setIndexOptions(-1) 96 | setPlaylistList([]) 97 | onUpdate() 98 | }) 99 | .catch(() => { }) 100 | } 101 | })) || []), 102 | ...(() => { 103 | if (!idPlaylist) return [] 104 | return ([ 105 | { 106 | name: 'Remove from playlist', 107 | icon: 'trash-o', 108 | onPress: () => { 109 | getApi(config, 'updatePlaylist', `playlistId=${idPlaylist}&songIndexToRemove=${songs[indexOptions].index}`) 110 | .then(() => { 111 | setIndexOptions(-1) 112 | setPlaylistList([]) 113 | onUpdate() 114 | }) 115 | .catch(() => { }) 116 | } 117 | } 118 | ]) 119 | })(), 120 | ...(() => { 121 | if (Platform.OS != 'web') return [] 122 | return ([{ 123 | name: 'Download', 124 | icon: 'download', 125 | onPress: async () => { 126 | setIndexOptions(-1) 127 | fetch(urlStream(config, songs[indexOptions].id)) 128 | .then((res) => res.blob()) 129 | .then((data) => { 130 | const a = document.createElement('a'); 131 | a.download = `${songs[indexOptions].artist} - ${songs[indexOptions].title}.mp3`; 132 | a.href = URL.createObjectURL(data); 133 | a.addEventListener('click', () => { 134 | setTimeout(() => URL.revokeObjectURL(a.href), 1 * 1000); 135 | }); 136 | a.click(); 137 | }) 138 | .catch(() => { }) 139 | } 140 | }]) 141 | })(), 142 | { 143 | name: 'Share', 144 | icon: 'share', 145 | onPress: () => { 146 | getApi(config, 'createShare', { id: songs[indexOptions].id }) 147 | .then((json) => { 148 | if (json.shares.share.length > 0) { 149 | if (Platform.OS === 'web') navigator.clipboard.writeText(json.shares.share[0].url) 150 | else Share.share({ message: json.shares.share[0].url }) 151 | } 152 | }) 153 | .catch(() => { }) 154 | reffOption.current.close() 155 | } 156 | }, 157 | { 158 | name: 'Info', 159 | icon: 'info', 160 | onPress: () => { 161 | setIndexOptions(-1) 162 | reffOption.current.setInfo(songs[indexOptions]) 163 | } 164 | }, 165 | ]} /> 166 | ); 167 | } 168 | 169 | export default OptionsSongsList; -------------------------------------------------------------------------------- /app/components/player/BoxPlayer.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Text, View, Pressable, StyleSheet } from 'react-native'; 3 | import { useSafeAreaInsets } from 'react-native-safe-area-context'; 4 | import Icon from 'react-native-vector-icons/FontAwesome'; 5 | 6 | import { SongContext, SongDispatchContext } from '~/contexts/song'; 7 | import { ConfigContext } from '~/contexts/config'; 8 | import { ThemeContext } from '~/contexts/theme'; 9 | import { urlCover } from '~/utils/api'; 10 | import PlayButton from '~/components/button/PlayButton'; 11 | import Player from '~/utils/player'; 12 | import IconButton from '~/components/button/IconButton'; 13 | import ImageError from '~/components/ImageError'; 14 | import size from '~/styles/size'; 15 | import useKeyboardIsOpen from '~/utils/useKeyboardIsOpen'; 16 | 17 | const BoxPlayer = ({ setFullScreen }) => { 18 | const song = React.useContext(SongContext) 19 | const songDispatch = React.useContext(SongDispatchContext) 20 | const config = React.useContext(ConfigContext) 21 | const insets = useSafeAreaInsets(); 22 | const theme = React.useContext(ThemeContext) 23 | const isKeyboardOpen = useKeyboardIsOpen() 24 | 25 | return ( 26 | setFullScreen(true)} 28 | style={{ 29 | position: 'absolute', 30 | bottom: (insets.bottom ? insets.bottom : 10) + 58, 31 | left: insets.left, 32 | right: insets.right, 33 | 34 | flexDirection: 'row', 35 | backgroundColor: theme.playerBackground, 36 | padding: 10, 37 | margin: 10, 38 | borderRadius: 10, 39 | display: isKeyboardOpen ? 'none' : undefined, 40 | }}> 41 | 45 | 46 | 47 | 48 | 49 | 50 | {song?.songInfo?.track ? `${song?.songInfo?.track}. ` : null}{song?.songInfo?.title ? song.songInfo.title : 'Song title'} 51 | {song?.songInfo?.artist ? song.songInfo.artist : 'Artist'} 52 | 53 | Player.nextSong(config, song, songDispatch)} 59 | /> 60 | 65 | 66 | ) 67 | } 68 | 69 | const styles = StyleSheet.create({ 70 | boxPlayerImage: { 71 | height: size.image.player, 72 | width: size.image.player, 73 | marginRight: 10, 74 | borderRadius: 4, 75 | alignItems: 'center', 76 | justifyContent: 'center', 77 | }, 78 | }) 79 | 80 | export default BoxPlayer; -------------------------------------------------------------------------------- /app/components/player/Lyric.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Text, FlatList, Pressable } from 'react-native' 3 | 4 | import { ThemeContext } from '~/contexts/theme' 5 | import { ConfigContext } from '~/contexts/config' 6 | import { getApi } from '~/utils/api' 7 | import { parseLrc } from '~/utils/lrc' 8 | import Player from '~/utils/player' 9 | import AsyncStorage from '@react-native-async-storage/async-storage' 10 | 11 | 12 | const Lyric = ({ song, time, style, color = null, sizeText = 23 }) => { 13 | const [indexCurrent, setIndex] = React.useState(0) 14 | const [lyrics, setLyrics] = React.useState([]) 15 | const config = React.useContext(ConfigContext) 16 | const refScroll = React.useRef(null) 17 | const theme = React.useContext(ThemeContext) 18 | 19 | React.useEffect(() => { 20 | getLyrics() 21 | }, [song.songInfo]) 22 | 23 | const getLyrics = () => { 24 | AsyncStorage.getItem(`lyrics/${song.songInfo.id}`) 25 | .then(res => { 26 | if (res) { 27 | const ly = JSON.parse(res) 28 | setLyrics(ly) 29 | } else { 30 | getNavidromeLyrics() 31 | } 32 | }) 33 | } 34 | 35 | React.useEffect(() => { 36 | if (lyrics.length == 0) return 37 | let index = lyrics.findIndex(ly => ly.time > time.position) - 1 38 | if (index === -1) index = 0 39 | if (index === -2) index = lyrics.length - 1 40 | if (index < 0) return 41 | if (index !== indexCurrent) { 42 | refScroll.current.scrollToIndex({ index, animated: true, viewOffset: 0, viewPosition: 0.5 }) 43 | setIndex(index) 44 | } 45 | }, [time.position]) 46 | 47 | const getNavidromeLyrics = () => { 48 | getApi(config, 'getLyricsBySongId', { id: song.songInfo.id }) 49 | .then(res => { 50 | const ly = res.lyricsList?.structuredLyrics[0]?.line?.map(ly => ({ time: ly.start / 1000, text: ly.value.length ? ly.value : '...' })) 51 | if (ly.length == 0) { // If not found 52 | return getLrcLibLyrics() 53 | } 54 | ly.sort((a, b) => a.time - b.time) 55 | setLyrics(ly) 56 | AsyncStorage.setItem(`lyrics/${song.songInfo.id}`, JSON.stringify(ly)) 57 | }) 58 | .catch(() => { // If not found 59 | getLrcLibLyrics() 60 | }) 61 | } 62 | 63 | const getLrcLibLyrics = () => { 64 | const params = { 65 | track_name: song.songInfo.title, 66 | artist_name: song.songInfo.artist, 67 | album_name: song.songInfo.album, 68 | duration: song.songInfo.duration 69 | } 70 | fetch('https://lrclib.net/api/get?' + Object.keys(params).map((key) => `${key}=${encodeURIComponent(params[key])}`).join('&'), { 71 | headers: { 'Lrclib-Client': 'Castafiore' } 72 | }) 73 | .then(res => res.json()) 74 | .then(res => { 75 | const ly = parseLrc(res.syncedLyrics) 76 | setLyrics(ly) 77 | AsyncStorage.setItem(`lyrics/${song.songInfo.id}`, JSON.stringify(ly)) 78 | }) 79 | .catch(() => { 80 | setLyrics([{ time: 0, text: 'No lyrics found' }]) 81 | }) 82 | } 83 | 84 | return ( 85 | { }} 91 | initialNumToRender={lyrics.length} 92 | data={lyrics} 93 | keyExtractor={(item, index) => index} 94 | renderItem={({ item, index }) => { 95 | return ( 96 | { 98 | Player.setPosition(item.time) 99 | }} 100 | > 101 | 107 | {item.text.length ? item.text : '...'} 108 | 109 | 110 | ) 111 | }} 112 | /> 113 | ) 114 | } 115 | 116 | export default Lyric -------------------------------------------------------------------------------- /app/components/player/Player.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useWindowDimensions } from 'react-native' 3 | 4 | import { SettingsContext } from '~/contexts/settings' 5 | import { SongContext } from '~/contexts/song' 6 | import BoxDesktopPlayer from '~/components/player/BoxDesktopPlayer' 7 | import BoxPlayer from '~/components/player/BoxPlayer' 8 | import FullScreenHorizontalPlayer from '~/components/player/FullScreenHorizontalPlayer' 9 | import FullScreenPlayer from '~/components/player/FullScreenPlayer' 10 | 11 | const Player = ({ state }) => { 12 | const song = React.useContext(SongContext) 13 | const settings = React.useContext(SettingsContext) 14 | const { height, width } = useWindowDimensions() 15 | const [fullScreen, setFullScreen] = React.useState(false) 16 | 17 | React.useEffect(() => { 18 | setFullScreen(false) 19 | }, [state.index]) 20 | 21 | if (!song?.songInfo) return null 22 | else if (fullScreen) { 23 | if (width <= height) return 24 | else return 25 | } 26 | else if (settings.isDesktop) return 27 | return 28 | } 29 | 30 | export default Player -------------------------------------------------------------------------------- /app/components/popup/ErrorPopup.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Text, Modal, Animated } from 'react-native'; 3 | import { useSafeAreaInsets } from 'react-native-safe-area-context'; 4 | 5 | import { ThemeContext } from '~/contexts/theme'; 6 | import presStyles from '~/styles/pres'; 7 | import size from '~/styles/size'; 8 | 9 | const ErrorPopup = ({ message, close }) => { 10 | const insets = useSafeAreaInsets(); 11 | const theme = React.useContext(ThemeContext) 12 | const [visible, setVisible] = React.useState(false); 13 | const slide = React.useRef(new Animated.Value(-200)).current; 14 | 15 | React.useEffect(() => { 16 | if (message) { 17 | setVisible(true) 18 | const timeout = setTimeout(() => { 19 | slide.setValue(-200) 20 | setVisible(false) 21 | close() 22 | }, 3000) 23 | Animated.timing(slide, { 24 | toValue: 0, 25 | duration: 300, 26 | useNativeDriver: true, 27 | }).start() 28 | return () => { 29 | clearTimeout(timeout) 30 | } 31 | } 32 | }, [message]) 33 | 34 | return ( 35 | 39 | 50 | Error 51 | {message} 52 | 53 | 54 | ) 55 | } 56 | 57 | export default ErrorPopup; -------------------------------------------------------------------------------- /app/components/popup/InfoPopup.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Text, View, Pressable, Modal, ScrollView } from 'react-native'; 3 | import { useSafeAreaInsets } from 'react-native-safe-area-context'; 4 | 5 | import { ThemeContext } from '~/contexts/theme'; 6 | import IconButton from '~/components/button/IconButton'; 7 | import settingStyles from '~/styles/settings'; 8 | import size from '~/styles/size'; 9 | import TableItem from '~/components/settings/TableItem'; 10 | 11 | const InfoPopup = ({ info, close }) => { 12 | const insets = useSafeAreaInsets(); 13 | const theme = React.useContext(ThemeContext) 14 | 15 | if (!info) return null; 16 | return ( 17 | 21 | 30 | 39 | 52 | Song Info 61 | 71 | 72 | { 73 | Object.keys(info).map((key, index) => ( 74 | 80 | )) 81 | } 82 | 83 | 84 | 85 | 86 | ) 87 | } 88 | 89 | export default InfoPopup; -------------------------------------------------------------------------------- /app/components/popup/OptionsPopup.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { View, Text, Modal, ScrollView, Animated, Pressable } from 'react-native'; 3 | import Icon from 'react-native-vector-icons/FontAwesome'; 4 | import { useSafeAreaInsets } from 'react-native-safe-area-context'; 5 | 6 | import { ConfigContext } from '~/contexts/config'; 7 | import { ThemeContext } from '~/contexts/theme'; 8 | import { urlCover } from '~/utils/api'; 9 | import ImageError from '~/components/ImageError'; 10 | import InfoPopup from '~/components/popup/InfoPopup'; 11 | import mainStyles from '~/styles/main'; 12 | import size from '~/styles/size'; 13 | 14 | const OptionsPopup = ({ reff, visible, close, options, item = null }) => { 15 | const insets = useSafeAreaInsets(); 16 | const theme = React.useContext(ThemeContext) 17 | const slide = React.useRef(new Animated.Value(-1000)).current 18 | const isAnim = React.useRef(false) 19 | const config = React.useContext(ConfigContext) 20 | const [info, setInfo] = React.useState(null) 21 | 22 | React.useImperativeHandle(reff, () => ({ 23 | close: close, 24 | setInfo: setInfo, 25 | }), [close]) 26 | 27 | React.useEffect(() => { 28 | if (!visible) slide.setValue(-10000) 29 | isAnim.current = true 30 | }, [visible]) 31 | 32 | const onLayout = (event) => { 33 | if (!isAnim.current) return 34 | isAnim.current = false 35 | slide.setValue(event.nativeEvent.layout.height) 36 | Animated.timing(slide, { 37 | toValue: 0, 38 | duration: 100, 39 | useNativeDriver: true 40 | }).start() 41 | } 42 | 43 | if (info) return ( 44 | setInfo(null)} /> 45 | ) 46 | if (!visible) return null; 47 | return ( 48 | 53 | 65 | 73 | 15 ? insets.bottom : 15, 79 | backgroundColor: theme.secondaryBack, 80 | borderTopLeftRadius: 20, 81 | borderTopRightRadius: 20, 82 | transform: [{ translateY: slide }] 83 | }} 84 | > 85 | { 86 | item && 87 | 100 | 109 | 110 | 111 | {item.track !== undefined ? `${item.track}. ` : null}{item.title || item.name} 112 | 113 | 114 | {item.artist || item.homePageUrl} 115 | 116 | 117 | 118 | } 119 | {[ 120 | ...options, 121 | { 122 | name: 'Cancel', 123 | icon: 'close', 124 | onPress: close 125 | } 126 | ].map((option, index) => { 127 | if (!option) return null 128 | return ( 129 | ([mainStyles.opacity({ pressed }), { 131 | flexDirection: 'row', 132 | alignItems: 'center', 133 | paddingHorizontal: 20, 134 | height: 45, 135 | justifyContent: 'flex-start', 136 | alignContent: 'center', 137 | }])} 138 | key={index} 139 | onPress={option.onPress}> 140 | 145 | {option.name} 149 | 150 | ) 151 | })} 152 | 153 | 154 | 155 | ) 156 | } 157 | 158 | export default OptionsPopup; -------------------------------------------------------------------------------- /app/components/settings/ButtonMenu.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Text, View, Pressable } from 'react-native'; 3 | import Icon from 'react-native-vector-icons/FontAwesome'; 4 | 5 | import { ThemeContext } from '~/contexts/theme'; 6 | import settingStyles from '~/styles/settings'; 7 | import size from '~/styles/size'; 8 | import mainStyles from '~/styles/main'; 9 | 10 | const ButtonMenu = ({ title, endText, onPress, icon, isLast = false }) => { 11 | const theme = React.useContext(ThemeContext) 12 | 13 | return ( 14 | ([mainStyles.opacity({ pressed }), settingStyles.optionItem(theme, isLast)])} 16 | onPress={onPress} 17 | > 18 | 28 | 33 | 34 | {title} 37 | 46 | {endText} 47 | 48 | 54 | 55 | ) 56 | } 57 | 58 | export default ButtonMenu; -------------------------------------------------------------------------------- /app/components/settings/ButtonSwitch.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Text, View, Pressable } from 'react-native'; 3 | import Icon from 'react-native-vector-icons/FontAwesome'; 4 | 5 | import { ThemeContext } from '~/contexts/theme'; 6 | import settingStyles from '~/styles/settings'; 7 | 8 | const ButtonSwitch = ({ title, value, onPress, icon = null, isLast = false }) => { 9 | const theme = React.useContext(ThemeContext) 10 | 11 | return ( 12 | 17 | {icon && 27 | } 32 | {title} 35 | 44 | 54 | 55 | 56 | ) 57 | } 58 | 59 | export default ButtonSwitch; -------------------------------------------------------------------------------- /app/components/settings/ButtonText.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { View, Text, Pressable, StyleSheet } from 'react-native' 3 | 4 | import { ThemeContext } from '~/contexts/theme' 5 | import mainStyles from '~/styles/main' 6 | import size from '~/styles/size'; 7 | 8 | const ButtonText = ({ text, onPress, disabled = false }) => { 9 | const theme = React.useContext(ThemeContext) 10 | 11 | return ( 12 | 13 | ([mainStyles.opacity({ pressed }), styles.button])} 15 | onPress={onPress} 16 | disabled={disabled} 17 | > 18 | {text} 19 | 20 | 21 | ) 22 | } 23 | 24 | const styles = StyleSheet.create({ 25 | main: { 26 | flexDirection: 'row', 27 | justifyContent: 'center', 28 | width: '100%', 29 | marginBottom: 20 30 | }, 31 | button: { 32 | width: '100%', 33 | height: 50, 34 | alignItems: "center", 35 | justifyContent: "center", 36 | }, 37 | text: theme => ({ 38 | color: theme.primaryTouch, 39 | fontSize: size.text.medium 40 | }) 41 | }) 42 | 43 | export default ButtonText -------------------------------------------------------------------------------- /app/components/settings/HomeOrder.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { View, Text, PanResponder, Animated, Pressable } from 'react-native'; 3 | import Icon from 'react-native-vector-icons/FontAwesome'; 4 | 5 | import { SettingsContext, SetSettingsContext } from '~/contexts/settings'; 6 | import { ThemeContext } from '~/contexts/theme'; 7 | import settingStyles from '~/styles/settings'; 8 | 9 | const HomeOrder = () => { 10 | const settings = React.useContext(SettingsContext) 11 | const setSettings = React.useContext(SetSettingsContext) 12 | const theme = React.useContext(ThemeContext) 13 | 14 | const moveElement = (index, direction) => { 15 | let newIndex = index + direction 16 | if (newIndex < 0) newIndex = 0 17 | if (newIndex >= settings.homeOrder.length) newIndex = settings.homeOrder.length - 1 18 | if (index === newIndex) return 19 | const newHomeOrder = [...settings.homeOrder] 20 | if (direction > 0) { 21 | newHomeOrder.splice(newIndex + 1, 0, newHomeOrder[index]) 22 | newHomeOrder.splice(index, 1) 23 | } else { 24 | newHomeOrder.splice(index, 1) 25 | newHomeOrder.splice(newIndex, 0, settings.homeOrder[index]) 26 | } 27 | setSettings({ ...settings, homeOrder: newHomeOrder }) 28 | } 29 | 30 | 31 | const onPressHomeOrder = (index) => { 32 | const newHomeOrder = [...settings.homeOrder] 33 | newHomeOrder[index].enable = !newHomeOrder[index].enable 34 | setSettings({ ...settings, homeOrder: newHomeOrder }) 35 | } 36 | 37 | return ( 38 | <> 39 | { 40 | settings.homeOrder.map((value, index) => { 41 | const startMove = React.useRef(0) 42 | const position = React.useRef(new Animated.Value(0)).current 43 | const panResponder = PanResponder.create({ 44 | onStartShouldSetPanResponder: () => true, 45 | onMoveShouldSetPanResponder: () => true, 46 | onPanResponderGrant: (_, gestureState) => { 47 | startMove.current = gestureState.y0 48 | position.setValue(0) 49 | }, 50 | onPanResponderMove: (_, gestureState) => { 51 | const move = gestureState.moveY - startMove.current 52 | position.setValue(move) 53 | }, 54 | onPanResponderRelease: () => { 55 | if (position._value === 0) { 56 | onPressHomeOrder(index) 57 | } 58 | moveElement(index, Math.round(position._value / 50)) 59 | position.setValue(0) 60 | startMove.current = 0 61 | } 62 | }) 63 | 64 | return ( 65 | 75 | onPressHomeOrder(index)} 77 | style={{ flex: 1, height: '100%', flexDirection: 'row', alignItems: 'center' }} 78 | > 79 | 80 | 85 | 86 | {value.title} 87 | 88 | 96 | 102 | 103 | 104 | ) 105 | }) 106 | } 107 | > 108 | ) 109 | } 110 | 111 | export default HomeOrder; -------------------------------------------------------------------------------- /app/components/settings/OptionInput.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Text, View, TextInput, Platform } from 'react-native'; 3 | 4 | import { ThemeContext } from '~/contexts/theme'; 5 | import settingStyles from '~/styles/settings'; 6 | import size from '~/styles/size'; 7 | 8 | const OptionInput = ({ title, placeholder, value, onChangeText, isPassword, autoComplete = 'off', inputMode = undefined, isLast = false, secureTextEntry = undefined }) => { 9 | const theme = React.useContext(ThemeContext) 10 | 11 | return ( 12 | 13 | {title} 16 | onChangeText(value)} 39 | /> 40 | 41 | ) 42 | } 43 | 44 | export default OptionInput; -------------------------------------------------------------------------------- /app/components/settings/SelectItem.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Pressable, Text } from 'react-native' 3 | import Icon from 'react-native-vector-icons/FontAwesome' 4 | 5 | import { ThemeContext } from '~/contexts/theme' 6 | import mainStyles from '~/styles/main'; 7 | import settingStyles from '~/styles/settings' 8 | import size from '~/styles/size'; 9 | 10 | const SelectItem = ({ text, onPress, icon, colorIcon = null, isSelect = false, disabled = false }) => { 11 | const theme = React.useContext(ThemeContext) 12 | 13 | return ( 14 | ([mainStyles.opacity({ pressed }), settingStyles.optionItem(theme, true)])} 17 | onPress={onPress}> 18 | 19 | 31 | {text} 32 | 33 | {isSelect && } 34 | 35 | ) 36 | } 37 | 38 | export default SelectItem -------------------------------------------------------------------------------- /app/components/settings/TableItem.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Text, Pressable, Platform } from 'react-native' 3 | 4 | import { ThemeContext } from '~/contexts/theme' 5 | import settingStyles from '~/styles/settings' 6 | import size from '~/styles/size'; 7 | 8 | const objectToString = (obj) => { 9 | if (obj === null || obj === undefined) { 10 | return 'N/A' 11 | } else if (typeof obj === 'object') { 12 | if (obj instanceof Array) { 13 | return obj.map(value => objectToString(value)).join(', ') 14 | } else { 15 | return Object.keys(obj).map(key => `${key}: ${objectToString(obj[key])}`).join('\n') 16 | } 17 | } else if (typeof obj === 'boolean') { 18 | return obj ? 'True' : 'False' 19 | } else { 20 | return obj 21 | } 22 | } 23 | 24 | const TableItem = ({ title, value, toCopy = null, onLongPress = () => {}, isLast = false }) => { 25 | const theme = React.useContext(ThemeContext) 26 | const [isCopied, setIsCopied] = React.useState(false) 27 | 28 | const onPress = () => { 29 | if (Platform.OS === 'web') { 30 | navigator.clipboard.writeText(toCopy || value) 31 | setIsCopied(true) 32 | setTimeout(() => setIsCopied(false), 1000) 33 | } 34 | } 35 | 36 | return ( 37 | 44 | {title} 51 | {isCopied ? 'Copied' : objectToString(value)} 60 | 61 | ) 62 | } 63 | 64 | export default TableItem 65 | -------------------------------------------------------------------------------- /app/contexts/config.js: -------------------------------------------------------------------------------- 1 | import AsyncStorage from '@react-native-async-storage/async-storage'; 2 | import React from 'react'; 3 | 4 | export const getConfig = async () => { 5 | const config = await AsyncStorage.getItem('config') 6 | if (config === null) return { url: null, username: null, query: null } 7 | return JSON.parse(config) 8 | } 9 | 10 | export const ConfigContext = React.createContext() 11 | export const SetConfigContext = React.createContext() -------------------------------------------------------------------------------- /app/contexts/settings.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import AsyncStorage from '@react-native-async-storage/async-storage'; 3 | import md5 from 'md5'; 4 | 5 | export const defaultSettings = { 6 | homeOrder: [ 7 | { icon: 'bar-chart', title: 'Week Activity', type: 'listenbrainz', query: '', enable: false }, 8 | { icon: 'user', title: 'Favorited Artist', type: 'artist', query: '', enable: true }, 9 | { icon: 'flask', title: 'Genre', type: 'genre', query: '', enable: false }, 10 | { icon: 'feed', title: 'Radio', type: 'radio', query: '', enable: false }, 11 | { icon: 'book', title: 'Pin Playlist', type: 'playlist', query: '', enable: false }, 12 | // { icon: 'group', title: 'Artists', type: 'artist_all', query: '', enable: false }, 13 | { icon: 'heart', title: 'Favorited', type: 'album_star', query: '', enable: true }, 14 | { icon: 'plus', title: 'Recently Added', type: 'album', query: 'type=newest', enable: true }, 15 | { icon: 'trophy', title: 'Most Played', type: 'album', query: 'type=frequent', enable: true }, 16 | { icon: 'play', title: 'Recently Played', type: 'album', query: 'type=recent', enable: true }, 17 | { icon: 'random', title: 'Random', type: 'album', query: 'type=random', enable: false }, 18 | { icon: 'arrow-up', title: 'Highest', type: 'album', query: 'type=highest', enable: false }, 19 | ], 20 | listenBrainzUser: '', 21 | sizeOfList: 15, 22 | orderPlaylist: 'title', 23 | previewFavorited: 3, 24 | isDesktop: false, 25 | servers: [ 26 | { 27 | name: 'Demo', 28 | url: 'https://demo.navidrome.org', 29 | username: 'demo', 30 | query: `u=${encodeURI('demo')}&t=${md5('demo' + 'aaaaaa')}&s=${'aaaaaa'}&v=1.16.1&c=castafiore` 31 | } 32 | ], 33 | cacheNextSong: 5, 34 | theme: 'castafiore', 35 | themePlayer: 'default', 36 | scrollHelper: false, 37 | showCache: false, 38 | streamFormat: 'raw', 39 | maxBitRate: 0, 40 | reversePlaylist: false, 41 | } 42 | 43 | export const getSettings = async () => { 44 | const config = await AsyncStorage.getItem('settings') 45 | if (config === null) return defaultSettings 46 | try { 47 | const data = JSON.parse(config) 48 | return { 49 | ...defaultSettings, 50 | ...data, 51 | } 52 | } catch { 53 | return defaultSettings 54 | } 55 | } 56 | 57 | export const SettingsContext = React.createContext() 58 | export const SetSettingsContext = React.createContext() 59 | 60 | export const getPathByType = (type) => { 61 | if (type === 'album') return 'getAlbumList' 62 | if (type === 'artist' || type === 'album_star') return 'getStarred' 63 | if (type === 'genre') return 'getGenres' 64 | if (type === 'artist_all') return 'getArtists' 65 | if (type === 'radio') return 'getInternetRadioStations' 66 | if (type === 'playlist') return 'getPlaylists' 67 | return type 68 | } 69 | 70 | export const setListByType = (json, type, setList) => { 71 | if (!json) return 72 | if (type == 'album') return setList(json?.albumList?.album) 73 | if (type == 'album_star') return setList(json?.starred?.album) 74 | if (type == 'artist') return setList(json?.starred?.artist) 75 | if (type == 'artist_all') return setList(json?.artists?.index.map((item) => item.artist).flat()) 76 | if (type == 'genre') return setList(json?.genres?.genre) 77 | if (type == 'radio') return setList(json?.internetRadioStations?.internetRadioStation) 78 | if (type == 'playlist') return setList(json?.playlists?.playlist) 79 | } -------------------------------------------------------------------------------- /app/contexts/song.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Player from '~/utils/player'; 3 | 4 | export const SongContext = React.createContext() 5 | export const SongDispatchContext = React.createContext() 6 | 7 | const newSong = (state, action) => { 8 | const song = { 9 | ...state, 10 | ...action, 11 | } 12 | if (window) window.song = song 13 | return song 14 | } 15 | 16 | export const songReducer = (state, action) => { 17 | switch (action.type) { 18 | case 'init': 19 | return newSong(state, { 20 | isInit: true, 21 | }) 22 | case 'setSong': 23 | return newSong(state, { 24 | songInfo: action.queue[action.index], 25 | index: action.index, 26 | queue: action.queue, 27 | }) 28 | case 'resetQueue': 29 | return newSong(state, { 30 | queue: null, 31 | index: 0, 32 | songInfo: null, 33 | }) 34 | case 'setIndex': 35 | if (!state.queue || state.queue?.length <= action.index) return state 36 | return newSong(state, { 37 | index: action.index, 38 | songInfo: state.queue[action.index], 39 | }) 40 | case 'next': { 41 | const nextIndex = (state.index + 1) % state.queue.length 42 | return newSong(state, { 43 | index: nextIndex, 44 | songInfo: state.queue[nextIndex], 45 | }) 46 | } 47 | case 'previous': { 48 | const previousIndex = (state.queue.length + state.index - 1) % state.queue.length 49 | return newSong(state, { 50 | index: previousIndex, 51 | songInfo: state.queue[previousIndex], 52 | }) 53 | } 54 | case 'setPlaying': { 55 | if (action.state === state.state || !action.state) return state 56 | return newSong(state, { 57 | state: action.state, 58 | }) 59 | } 60 | case 'addQueue': 61 | if (!state.songInfo || !state.queue.length || !action.queue.length) return state 62 | return newSong(state, { 63 | queue: [...state.queue, ...action.queue], 64 | }) 65 | case 'setActionEndOfSong': 66 | if (['next', 'repeat'].indexOf(action.action) === -1) return state 67 | return newSong(state, { 68 | actionEndOfSong: action.action, 69 | }) 70 | default: 71 | console.error('Unknown action', action) 72 | return state 73 | } 74 | } 75 | 76 | export const defaultSong = { 77 | isInit: false, 78 | songInfo: null, 79 | queue: null, 80 | index: 0, 81 | actionEndOfSong: 'next', 82 | state: Player.State.Stopped, 83 | } -------------------------------------------------------------------------------- /app/contexts/updateApi.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const UpdateApiContext = React.createContext() 4 | export const SetUpdateApiContext = React.createContext() 5 | 6 | export const isUpdatable = (updateApi, path, query) => { 7 | if (updateApi.path !== path) return false 8 | if (!updateApi.query && !query) return true 9 | if (updateApi.query === query) return true 10 | return false 11 | } -------------------------------------------------------------------------------- /app/screens/Album.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Text, View, Image, ScrollView, Pressable } from 'react-native'; 3 | import { useSafeAreaInsets } from 'react-native-safe-area-context'; 4 | 5 | import { ConfigContext } from '~/contexts/config'; 6 | import { useCachedAndApi } from '~/utils/api'; 7 | import { ThemeContext } from '~/contexts/theme'; 8 | import { urlCover } from '~/utils/api'; 9 | import BackButton from '~/components/button/BackButton'; 10 | import FavoritedButton from '~/components/button/FavoritedButton'; 11 | import mainStyles from '~/styles/main'; 12 | import presStyles from '~/styles/pres'; 13 | import RandomButton from '~/components/button/RandomButton'; 14 | import SongsList from '~/components/lists/SongsList'; 15 | import size from '~/styles/size'; 16 | 17 | const Album = ({ navigation, route: { params } }) => { 18 | const insets = useSafeAreaInsets(); 19 | const config = React.useContext(ConfigContext) 20 | const theme = React.useContext(ThemeContext) 21 | const [isStarred, setStarred] = React.useState(params.starred || false) 22 | 23 | const [songs] = useCachedAndApi([], 'getAlbum', `id=${params.id}`, (json, setData) => { 24 | setStarred(json?.album?.starred) 25 | setData(json?.album?.song.sort((a, b) => { 26 | // sort by discNumber and track 27 | if (a.discNumber < b.discNumber) return -1; 28 | if (a.discNumber > b.discNumber) return 1; 29 | if (a.track < b.track) return -1; 30 | if (a.track > b.track) return 1; 31 | return 0; 32 | })) 33 | }, [params.id]) 34 | 35 | return ( 36 | 40 | 41 | 47 | 48 | 49 | {params.name} 50 | navigation.navigate('Artist', { id: params.artistId, name: params.artist })} 53 | > 54 | {params.artist} 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | ) 63 | } 64 | 65 | export default Album; -------------------------------------------------------------------------------- /app/screens/Artist.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Text, View, Image, ScrollView } from 'react-native'; 3 | import { useSafeAreaInsets } from 'react-native-safe-area-context'; 4 | 5 | import { SongDispatchContext } from '~/contexts/song'; 6 | import { ConfigContext } from '~/contexts/config'; 7 | import { ThemeContext } from '~/contexts/theme'; 8 | import { playSong } from '~/utils/player'; 9 | import { urlCover, useCachedAndApi, getApiNetworkFirst } from '~/utils/api'; 10 | import { shuffle } from '~/utils/tools'; 11 | import mainStyles from '~/styles/main'; 12 | import presStyles from '~/styles/pres'; 13 | import BackButton from '~/components/button/BackButton'; 14 | import FavoritedButton from '~/components/button/FavoritedButton'; 15 | import HorizontalAlbums from '~/components/lists/HorizontalAlbums'; 16 | import HorizontalArtists from '~/components/lists/HorizontalArtists'; 17 | import IconButton from '~/components/button/IconButton'; 18 | import SongsList from '~/components/lists/SongsList'; 19 | import size from '~/styles/size'; 20 | 21 | const Artist = ({ route: { params } }) => { 22 | const insets = useSafeAreaInsets(); 23 | const config = React.useContext(ConfigContext) 24 | const songDispatch = React.useContext(SongDispatchContext) 25 | const theme = React.useContext(ThemeContext) 26 | const allSongs = React.useRef([]) 27 | const [sortAlbum, setSortAlbum] = React.useState([]) 28 | 29 | const [artistInfo] = useCachedAndApi([], 'getArtistInfo', `id=${params.id}`, (json, setData) => { 30 | setData(json.artistInfo) 31 | }, [params.id]) 32 | 33 | const [artist] = useCachedAndApi([], 'getArtist', `id=${params.id}`, (json, setData) => { 34 | setData(json.artist) 35 | setSortAlbum(json.artist?.album?.sort((a, b) => b.year - a.year)) 36 | }, [params.id]) 37 | 38 | const [favorited] = useCachedAndApi([], 'getStarred', null, (json, setData) => { 39 | setData(json.starred.song.filter(song => song.artistId === params.id)) 40 | }, [params.id]) 41 | 42 | const getRandomSongs = async () => { 43 | if (!artist.album) return 44 | if (!allSongs.current.length) { 45 | const songsPending = artist.album.map(async album => { 46 | return await getApiNetworkFirst(config, 'getAlbum', `id=${album.id}`) 47 | .then((json) => { 48 | return json.album.song 49 | }) 50 | .catch(() => { }) 51 | }) 52 | allSongs.current = (await Promise.all(songsPending)).flat() 53 | } 54 | playSong(config, songDispatch, shuffle(allSongs.current), 0) 55 | } 56 | 57 | const getTopSongs = () => { 58 | getApiNetworkFirst(config, 'getTopSongs', { artist: params.name, count: 50 }) 59 | .then((json) => { 60 | const songs = json.topSongs?.song 61 | if (!songs) return 62 | playSong(config, songDispatch, songs, 0) 63 | }) 64 | .catch(() => { }) 65 | } 66 | 67 | return ( 68 | 72 | 73 | 79 | 80 | 81 | {params.name} 82 | Artist {params.id === undefined ? 'not found' : ''} 83 | 84 | 90 | 96 | 102 | 103 | Albums 104 | 105 | { 106 | artistInfo?.similarArtist?.length && ( 107 | <> 108 | Similar Artist 109 | 110 | > 111 | ) 112 | } 113 | { 114 | favorited?.length ? ( 115 | <> 116 | Favorited Songs 117 | 118 | > 119 | ) : null 120 | } 121 | 122 | ) 123 | } 124 | 125 | export default Artist; -------------------------------------------------------------------------------- /app/screens/ArtistExplorer.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Text, View, SectionList, StyleSheet, Pressable } from 'react-native'; 3 | import { useSafeAreaInsets } from 'react-native-safe-area-context'; 4 | import { useNavigation } from '@react-navigation/native'; 5 | import Icon from 'react-native-vector-icons/FontAwesome'; 6 | 7 | import { useCachedAndApi } from '~/utils/api'; 8 | import { ThemeContext } from '~/contexts/theme'; 9 | import BackButton from '~/components/button/BackButton'; 10 | import mainStyles from '~/styles/main'; 11 | import presStyles from '~/styles/pres'; 12 | import ImageError from '~/components/ImageError'; 13 | import size from '~/styles/size'; 14 | import FavoritedButton from '~/components/button/FavoritedButton'; 15 | 16 | const ArtistExplorer = () => { 17 | const insets = useSafeAreaInsets(); 18 | const theme = React.useContext(ThemeContext) 19 | const navigation = useNavigation(); 20 | 21 | const [artists] = useCachedAndApi([], 'getArtists', null, (json, setData) => { 22 | setData(json?.artists?.index.map(item => ({ 23 | title: item.name, 24 | data: item.artist 25 | })) || []); 26 | }) 27 | 28 | const [favorited] = useCachedAndApi([], 'getStarred', null, (json, setData) => { 29 | setData(json?.starred?.artist || []); 30 | }, []); 31 | 32 | 33 | const isFavorited = React.useCallback((id) => { 34 | return favorited.some(fav => fav.id === id); 35 | }, [favorited]); 36 | 37 | return ( 38 | <> 39 | item.id || index.toString()} 42 | style={mainStyles.mainContainer(theme)} 43 | contentContainerStyle={[mainStyles.contentMainContainer(insets, false)]} 44 | ListHeaderComponent={ 45 | <> 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | Artists 54 | 55 | 56 | 57 | > 58 | } 59 | renderItem={({ item }) => ( 60 | { 62 | navigation.navigate('Artist', { id: item.id, name: item.name }); 63 | }} 64 | style={{ 65 | marginHorizontal: 20, 66 | marginBottom: 10, 67 | flexDirection: 'row', 68 | gap: 10, 69 | }}> 70 | 80 | 85 | 94 | {item.name} 95 | 96 | 97 | {item.albumCount} albums 98 | 99 | 100 | 106 | 107 | )} 108 | renderSectionHeader={({ section }) => ( 109 | {section.title} 114 | )} 115 | /> 116 | > 117 | ); 118 | } 119 | 120 | const styles = StyleSheet.create({ 121 | cover: { 122 | width: "100%", 123 | height: 300, 124 | flexDirection: 'column', 125 | justifyContent: 'center', 126 | alignItems: 'center', 127 | backgroundColor: '#c68588', 128 | }, 129 | }) 130 | 131 | export default ArtistExplorer; -------------------------------------------------------------------------------- /app/screens/Favorited.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Text, View, ScrollView, StyleSheet } from 'react-native'; 3 | import { useSafeAreaInsets } from 'react-native-safe-area-context'; 4 | import Icon from 'react-native-vector-icons/FontAwesome'; 5 | 6 | import { ThemeContext } from '~/contexts/theme'; 7 | import SongsList from '~/components/lists/SongsList'; 8 | import mainStyles from '~/styles/main'; 9 | import presStyles from '~/styles/pres'; 10 | import RandomButton from '~/components/button/RandomButton'; 11 | import BackButton from '~/components/button/BackButton'; 12 | import size from '~/styles/size'; 13 | 14 | const Favorited = ({ route: { params } }) => { 15 | const insets = useSafeAreaInsets(); 16 | const theme = React.useContext(ThemeContext) 17 | 18 | return ( 19 | 23 | 24 | 27 | 28 | 29 | 30 | 31 | Favorited 32 | {params.favorited?.length || 0} songs 33 | 34 | 35 | 36 | 37 | 38 | ) 39 | } 40 | 41 | const styles = StyleSheet.create({ 42 | cover: { 43 | width: "100%", 44 | height: 300, 45 | flexDirection: 'column', 46 | justifyContent: 'center', 47 | alignItems: 'center', 48 | backgroundColor: '#c68588', 49 | }, 50 | }) 51 | 52 | export default Favorited; -------------------------------------------------------------------------------- /app/screens/Genre.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Text, View, ScrollView, StyleSheet } from 'react-native'; 3 | import { useSafeAreaInsets } from 'react-native-safe-area-context'; 4 | import Icon from 'react-native-vector-icons/FontAwesome'; 5 | 6 | import { ConfigContext } from '~/contexts/config'; 7 | import { getApiNetworkFirst } from '~/utils/api'; 8 | import { playSong } from '~/utils/player'; 9 | import { SongDispatchContext } from '~/contexts/song'; 10 | import { ThemeContext } from '~/contexts/theme'; 11 | import { useCachedAndApi } from '~/utils/api'; 12 | import BackButton from '~/components/button/BackButton'; 13 | import HorizontalAlbums from '~/components/lists/HorizontalAlbums'; 14 | import IconButton from '~/components/button/IconButton'; 15 | import mainStyles from '~/styles/main'; 16 | import presStyles from '~/styles/pres'; 17 | import SongsList from '~/components/lists/SongsList'; 18 | import size from '~/styles/size'; 19 | 20 | const Genre = ({ route: { params } }) => { 21 | const insets = useSafeAreaInsets(); 22 | const config = React.useContext(ConfigContext) 23 | const songDispatch = React.useContext(SongDispatchContext) 24 | const theme = React.useContext(ThemeContext) 25 | 26 | const [albums] = useCachedAndApi([], 'getAlbumList', { type: 'byGenre', genre: params.genre.value }, (json, setData) => { 27 | setData(json?.albumList?.album) 28 | }) 29 | 30 | const [songs] = useCachedAndApi([], 'getSongsByGenre', { genre: params.genre.value, count: 50 }, (json, setData) => { 31 | setData(json?.songsByGenre?.song) 32 | }) 33 | 34 | const getRandomSongs = () => { 35 | getApiNetworkFirst(config, 'getRandomSongs', { genre: params.genre.value, count: 50 }) 36 | .then((json) => { 37 | const songs = json.randomSongs?.song 38 | if (!songs) return 39 | playSong(config, songDispatch, songs, 0) 40 | }) 41 | .catch(() => { }) 42 | } 43 | 44 | return ( 45 | 50 | 51 | 54 | {params.genre.value} 55 | 56 | 57 | 58 | {params.genre.value} 59 | {params.genre?.albumCount || 0} albums · {params.genre?.songCount || 0} songs 60 | 61 | 67 | 68 | Albums 69 | 70 | Songs 71 | 72 | 73 | ) 74 | } 75 | 76 | const styles = StyleSheet.create({ 77 | title: { 78 | color: '#F9F2F3', 79 | fontSize: 50, 80 | fontWeight: 'bold', 81 | }, 82 | cover: { 83 | width: "100%", 84 | height: 300, 85 | flexDirection: 'column', 86 | justifyContent: 'center', 87 | alignItems: 'center', 88 | backgroundColor: '#c68588', 89 | }, 90 | }) 91 | 92 | export default Genre; -------------------------------------------------------------------------------- /app/screens/Playlist.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Text, View, TextInput, Image, FlatList, Pressable } from 'react-native'; 3 | import { useSafeAreaInsets } from 'react-native-safe-area-context'; 4 | 5 | import { SettingsContext } from '~/contexts/settings'; 6 | import { ConfigContext } from '~/contexts/config'; 7 | import { getApi, urlCover, useCachedAndApi } from '~/utils/api'; 8 | import { ThemeContext } from '~/contexts/theme'; 9 | import BackButton from '~/components/button/BackButton'; 10 | import mainStyles from '~/styles/main'; 11 | import presStyles from '~/styles/pres'; 12 | import RandomButton from '~/components/button/RandomButton'; 13 | import SongItem from '~/components/lists/SongItem'; 14 | import OptionsSongsList from '../components/options/OptionsSongsList'; 15 | 16 | const Playlist = ({ route: { params } }) => { 17 | const insets = useSafeAreaInsets(); 18 | const config = React.useContext(ConfigContext) 19 | const theme = React.useContext(ThemeContext) 20 | const settings = React.useContext(SettingsContext) 21 | const [info, setInfo] = React.useState(null) 22 | const [title, setTitle] = React.useState(null) 23 | const [indexOptions, setIndexOptions] = React.useState(-1) 24 | 25 | const [songs, refresh] = useCachedAndApi([], 'getPlaylist', `id=${params.playlist.id}`, (json, setData) => { 26 | setInfo(json?.playlist) 27 | if (settings.reversePlaylist) setData(json?.playlist?.entry?.map((item, index) => ({ ...item, index })).reverse() || []) 28 | else setData(json?.playlist?.entry?.map((item, index) => ({ ...item, index })) || []) 29 | }, [params.playlist.id, settings.reversePlaylist]) 30 | 31 | 32 | return ( 33 | <> 34 | item.id || index.toString()} 37 | style={mainStyles.mainContainer(theme)} 38 | contentContainerStyle={[mainStyles.contentMainContainer(insets, false)]} 39 | ListHeaderComponent={ 40 | <> 41 | 42 | 48 | 49 | 50 | { 51 | title != null ? ( 52 | setTitle(text)} 56 | autoFocus={true} 57 | onSubmitEditing={() => { 58 | getApi(config, 'updatePlaylist', `playlistId=${params.playlist.id}&name=${title}`) 59 | .then(() => { 60 | setTitle(null); 61 | refresh(); 62 | }) 63 | .catch(() => { }); 64 | }} 65 | onBlur={() => setTitle(null)} 66 | /> 67 | ) : ( 68 | setTitle(info.name)} 71 | delayLongPress={200} 72 | > 73 | 74 | {info?.name || params.playlist?.name} 75 | 76 | 77 | ) 78 | } 79 | 80 | {((info?.duration || params?.playlist?.duration) / 60) | 1} minutes · {info?.songCount || params?.playlist?.songCount} songs 81 | 82 | 83 | 84 | 85 | > 86 | } 87 | renderItem={({ item, index }) => ( 88 | 97 | )} 98 | /> 99 | 106 | > 107 | ); 108 | } 109 | 110 | export default Playlist; -------------------------------------------------------------------------------- /app/screens/Settings/AddServer.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Text, View, ScrollView, Platform } from 'react-native'; 3 | import { useSafeAreaInsets } from 'react-native-safe-area-context'; 4 | import AsyncStorage from '@react-native-async-storage/async-storage'; 5 | import Icon from 'react-native-vector-icons/FontAwesome'; 6 | import md5 from 'md5'; 7 | 8 | import { SetConfigContext } from '~/contexts/config'; 9 | import { getApi } from '~/utils/api'; 10 | import { SettingsContext, SetSettingsContext } from '~/contexts/settings'; 11 | import { ThemeContext } from '~/contexts/theme'; 12 | import ButtonText from '~/components/settings/ButtonText'; 13 | import ButtonSwitch from '~/components/settings/ButtonSwitch'; 14 | import Header from '~/components/Header'; 15 | import mainStyles from '~/styles/main'; 16 | import OptionInput from '~/components/settings/OptionInput'; 17 | import settingStyles from '~/styles/settings'; 18 | import size from '~/styles/size'; 19 | 20 | const AddServer = ({ navigation }) => { 21 | const insets = useSafeAreaInsets() 22 | const setConfig = React.useContext(SetConfigContext) 23 | const settings = React.useContext(SettingsContext) 24 | const setSettings = React.useContext(SetSettingsContext) 25 | const theme = React.useContext(ThemeContext) 26 | const [name, setName] = React.useState(''); 27 | const [url, setUrl] = React.useState(''); 28 | const [username, setUsername] = React.useState(''); 29 | const [password, setPassword] = React.useState(''); 30 | const [error, setError] = React.useState(''); 31 | const [info, setInfo] = React.useState(null) 32 | const [showPassword, setShowPassword] = React.useState(false) 33 | 34 | const upConfig = (conf) => { 35 | AsyncStorage.setItem('config', JSON.stringify(conf)) 36 | setConfig(conf) 37 | } 38 | 39 | const connect = () => { 40 | const uri = url.replace(/\/$/, '') 41 | setUrl(uri) 42 | const salt = Math.random().toString(36).substring(2, 15) 43 | const query = `u=${encodeURI(username)}&t=${md5(password + salt)}&s=${salt}&v=1.16.1&c=castafiore` 44 | 45 | if (Platform.OS !== 'android' && uri.startsWith('http://')) { 46 | setError('Only https is allowed') 47 | return 48 | } 49 | getApi({ url: uri, query }, 'ping.view') 50 | .then((json) => { 51 | if (json?.status == 'ok') { 52 | setInfo(json) 53 | const conf = { name, url: uri, username, query } 54 | upConfig(conf) 55 | setError('') 56 | setSettings({ ...settings, servers: [...settings.servers, conf] }) 57 | navigation.goBack() 58 | navigation.navigate('HomeStack') 59 | } else { 60 | console.log('Connect api error:', json) 61 | } 62 | }) 63 | .catch((error) => { 64 | console.log('Connect error:', error) 65 | if (error.isApiError || error.message) setError(error.message) 66 | else setError('Failed to connect to server') 67 | }) 68 | } 69 | 70 | return ( 71 | 75 | 76 | 77 | 78 | 79 | 88 | 89 | 90 | 91 | {!error.length && } 92 | 93 | {(() => { 94 | if (error.length) return error 95 | else if (info) return `${info.type.charAt(0).toUpperCase()}${info.type.slice(1)} ${info.serverVersion}` 96 | else return 'Not connected' 97 | })()} 98 | 99 | 100 | 101 | 102 | 103 | setName(name)} 109 | /> 110 | setUrl(url)} 117 | /> 118 | setUsername(username)} 125 | autoComplete="username" 126 | /> 127 | setPassword(password)} 134 | isPassword={true} 135 | secureTextEntry={!showPassword} 136 | autoComplete="current-password" 137 | isLast={true} 138 | /> 139 | 140 | 141 | setShowPassword(!showPassword)} 145 | isLast={true} 146 | /> 147 | 148 | 152 | 153 | 154 | ) 155 | } 156 | 157 | export default AddServer; -------------------------------------------------------------------------------- /app/screens/Settings/Cache.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { View, Text, ScrollView, Platform } from 'react-native' 3 | import { useSafeAreaInsets } from 'react-native-safe-area-context' 4 | import Icon from 'react-native-vector-icons/FontAwesome' 5 | 6 | import { clearCache } from '~/utils/cache' 7 | import { confirmAlert } from '~/utils/alert' 8 | import { getStatCache } from '~/utils/cache' 9 | import { SettingsContext, SetSettingsContext } from '~/contexts/settings' 10 | import { ThemeContext } from '~/contexts/theme' 11 | import ButtonMenu from '~/components/settings/ButtonMenu' 12 | import ButtonSwitch from '~/components/settings/ButtonSwitch' 13 | import Header from '~/components/Header' 14 | import mainStyles from '~/styles/main' 15 | import OptionInput from '~/components/settings/OptionInput' 16 | import settingStyles from '~/styles/settings' 17 | import TableItem from '~/components/settings/TableItem' 18 | import ListMap from '~/components/lists/ListMap' 19 | import size from '~/styles/size'; 20 | 21 | const CacheSettings = () => { 22 | const insets = useSafeAreaInsets() 23 | const settings = React.useContext(SettingsContext) 24 | const setSettings = React.useContext(SetSettingsContext) 25 | const theme = React.useContext(ThemeContext) 26 | const [cacheNextSong, setCacheNextSong] = React.useState(settings.cacheNextSong.toString()) 27 | const [statCache, setStatCache] = React.useState([ 28 | { name: 'Loading...', count: '' }, 29 | ]) 30 | 31 | const getStat = () => { 32 | getStatCache() 33 | .then((res) => { 34 | setStatCache(res) 35 | }) 36 | } 37 | 38 | React.useEffect(() => { 39 | getStat() 40 | }, []) 41 | 42 | React.useEffect(() => { 43 | setCacheNextSong(settings.cacheNextSong.toString()) 44 | }, [settings.cacheNextSong]) 45 | 46 | React.useEffect(() => { 47 | if (cacheNextSong === '') return 48 | const number = parseInt(cacheNextSong) 49 | if (number === settings.cacheNextSong) return 50 | setSettings({ ...settings, cacheNextSong: number }) 51 | }, [cacheNextSong]) 52 | 53 | return ( 54 | 58 | 59 | 60 | 61 | { 62 | Platform.OS === 'android' && 63 | 64 | 65 | 71 | Song caching is not yet supported on Android. 72 | 78 | 79 | 80 | } 81 | Auto Cache 82 | 83 | setCacheNextSong(text.replace(/[^0-9]/g, ''))} 87 | inputMode="numeric" 88 | isLast={true} 89 | /> 90 | 91 | {'Auto download upcoming songs (default: 5)'} 92 | 93 | setSettings({ ...settings, showCache: !settings.showCache })} 97 | isLast={true} 98 | /> 99 | 100 | 101 | confirmAlert( 105 | 'Clear cache', 106 | 'Are you sure you want to clear the cache?', 107 | async () => { 108 | await clearCache() 109 | getStat() 110 | } 111 | )} 112 | isLast={true} 113 | /> 114 | 115 | Cache Stats 116 | 117 | ( 120 | 126 | )} 127 | ListEmptyComponent={( 128 | 129 | No Cache 130 | 131 | )} 132 | /> 133 | 134 | 135 | 136 | ) 137 | } 138 | 139 | export default CacheSettings; -------------------------------------------------------------------------------- /app/screens/Settings/Home.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Text, View, ScrollView } from 'react-native'; 3 | import { useSafeAreaInsets } from 'react-native-safe-area-context'; 4 | 5 | import { SettingsContext, SetSettingsContext } from '~/contexts/settings'; 6 | import { ThemeContext } from '~/contexts/theme'; 7 | import ButtonSwitch from '~/components/settings/ButtonSwitch'; 8 | import Header from '~/components/Header'; 9 | import HomeOrder from '~/components/settings/HomeOrder'; 10 | import OptionInput from '~/components/settings/OptionInput'; 11 | import mainStyles from '~/styles/main'; 12 | import settingStyles from '~/styles/settings'; 13 | 14 | const HomeSettings = () => { 15 | const insets = useSafeAreaInsets() 16 | const theme = React.useContext(ThemeContext) 17 | const settings = React.useContext(SettingsContext) 18 | const setSettings = React.useContext(SetSettingsContext) 19 | const [sizeOfList, setSizeOfList] = React.useState(settings.sizeOfList.toString()) 20 | const [LBUser, setLBUser] = React.useState(settings.listenBrainzUser) 21 | 22 | React.useEffect(() => { 23 | setSizeOfList(settings.sizeOfList.toString()) 24 | }, [settings.sizeOfList]) 25 | 26 | React.useEffect(() => { 27 | if (sizeOfList === '') return 28 | const number = parseInt(sizeOfList) 29 | if (number != settings.sizeOfList) setSettings({ ...settings, sizeOfList: number }) 30 | }, [sizeOfList]) 31 | 32 | React.useEffect(() => { 33 | if (LBUser != settings.listenBrainzUser) setSettings({ ...settings, listenBrainzUser: LBUser }) 34 | }, [LBUser]) 35 | 36 | return ( 37 | 41 | 42 | 45 | Home Page 46 | 47 | 48 | 49 | {'Select what you want to see on the home page'} 50 | 51 | setSizeOfList(text.replace(/[^0-9]/g, ''))} 55 | inputMode="numeric" 56 | isLast={true} 57 | /> 58 | 59 | 60 | Scroll 61 | 62 | setSettings({ ...settings, scrollHelper: !settings.scrollHelper })} 65 | value={settings.scrollHelper} 66 | isLast={true} 67 | /> 68 | 69 | {'It\'s recommanded to activate scroll helper on desktop'} 70 | 71 | 72 | setLBUser(text)} 76 | placeholder="user" 77 | isLast={true} 78 | /> 79 | 80 | 81 | 82 | ) 83 | } 84 | 85 | export default HomeSettings; -------------------------------------------------------------------------------- /app/screens/Settings/Informations.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { View, Text, ScrollView } from 'react-native' 3 | import { useSafeAreaInsets } from 'react-native-safe-area-context' 4 | 5 | import { ConfigContext } from '~/contexts/config' 6 | import { useCachedAndApi } from '~/utils/api' 7 | import { ThemeContext } from '~/contexts/theme' 8 | import Header from '~/components/Header' 9 | import mainStyles from '~/styles/main' 10 | import settingStyles from '~/styles/settings' 11 | import TableItem from '~/components/settings/TableItem' 12 | 13 | const ROLES = [ 14 | "adminRole", 15 | "podcastRole", 16 | "streamRole", 17 | "jukeboxRole", 18 | "shareRole", 19 | "videoConversionRole", 20 | "scrobblingEnabled", 21 | "settingsRole", 22 | "downloadRole", 23 | "uploadRole", 24 | "playlistRole", 25 | "coverArtRole", 26 | "commentRole", 27 | ] 28 | 29 | const InformationsSettings = () => { 30 | const insets = useSafeAreaInsets() 31 | const theme = React.useContext(ThemeContext) 32 | const config = React.useContext(ConfigContext) 33 | const [server, setServer] = React.useState({}) 34 | 35 | const [user] = useCachedAndApi([], 'getUser', {}, (json, setData) => { 36 | setServer({ 37 | version: json.serverVersion, 38 | name: json.type, 39 | apiVersion: json.version, 40 | connected: json.status === 'ok', 41 | }) 42 | setData(json.user) 43 | }) 44 | const [scan] = useCachedAndApi([], 'getScanStatus', {}, (json, setData) => { 45 | setData(json.scanStatus) 46 | }) 47 | 48 | const convertDate = (date) => { 49 | if (!date) return '' 50 | const d = new Date(date) 51 | return d.toLocaleString() 52 | } 53 | 54 | return ( 55 | 59 | 60 | 61 | 62 | Server 63 | 64 | 68 | 72 | 76 | 80 | 85 | 86 | 87 | 88 | Scan 89 | 90 | 94 | 98 | 103 | 104 | 105 | 106 | User 107 | 108 | 112 | 117 | 118 | 119 | 120 | User Role 121 | 122 | { 123 | ROLES.map((role, index) => { 124 | if (!(role in user)) return null 125 | return ( 126 | 132 | ) 133 | }) 134 | } 135 | 136 | 137 | 138 | ) 139 | } 140 | 141 | export default InformationsSettings; -------------------------------------------------------------------------------- /app/screens/Settings/Player.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { View, Text, ScrollView } from 'react-native' 3 | import { useSafeAreaInsets } from 'react-native-safe-area-context' 4 | 5 | import { SettingsContext } from '~/contexts/settings' 6 | import { SetSettingsContext } from '~/contexts/settings' 7 | import { ThemeContext } from '~/contexts/theme' 8 | import Header from '~/components/Header' 9 | import mainStyles from '~/styles/main' 10 | import settingStyles from '~/styles/settings' 11 | import SelectItem from '~/components/settings/SelectItem' 12 | 13 | const FORMATS = [ 14 | { name: 'Raw', value: 'raw' }, 15 | { name: 'MP3', value: 'mp3' }, 16 | { name: 'AAC', value: 'aac' }, 17 | { name: 'Opus', value: 'opus' }, 18 | ] 19 | 20 | const BITRATES = [ 21 | { name: 'Default', value: 0 }, 22 | { name: '32', value: 32 }, 23 | { name: '48', value: 48 }, 24 | { name: '64', value: 64 }, 25 | { name: '80', value: 80 }, 26 | { name: '96', value: 96 }, 27 | { name: '112', value: 112 }, 28 | { name: '128', value: 128 }, 29 | { name: '160', value: 160 }, 30 | { name: '192', value: 192 }, 31 | { name: '256', value: 256 }, 32 | { name: '320', value: 320 }, 33 | ] 34 | 35 | const PlayerSettings = () => { 36 | const insets = useSafeAreaInsets() 37 | const theme = React.useContext(ThemeContext) 38 | const settings = React.useContext(SettingsContext) 39 | const setSettings = React.useContext(SetSettingsContext) 40 | 41 | return ( 42 | 46 | 47 | 48 | 49 | Format stream 50 | 51 | {FORMATS.map((item, index) => ( 52 | { 58 | setSettings({ ...settings, streamFormat: item.value }) 59 | }} 60 | /> 61 | ))} 62 | 63 | Specify the format of the stream to be played. 64 | 65 | Max bit rate 66 | 67 | { 68 | BITRATES.map((item, index) => ( 69 | { 75 | setSettings({ ...settings, maxBitRate: item.value }) 76 | }} 77 | disabled={settings.streamFormat === 'raw'} 78 | /> 79 | )) 80 | } 81 | 82 | Specify the maximum bit rate in kilobits per second for the stream to be played. Lower bit rates will consume less data but may result in lower audio quality. 83 | 84 | 85 | ) 86 | } 87 | 88 | export default PlayerSettings -------------------------------------------------------------------------------- /app/screens/Settings/Playlists.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { View, Text } from 'react-native' 3 | import { useSafeAreaInsets } from 'react-native-safe-area-context' 4 | 5 | import { SettingsContext, SetSettingsContext } from '~/contexts/settings' 6 | import { ThemeContext } from '~/contexts/theme' 7 | import Header from '~/components/Header' 8 | import mainStyles from '~/styles/main' 9 | import OptionInput from '~/components/settings/OptionInput' 10 | import settingStyles from '~/styles/settings' 11 | import SelectItem from '~/components/settings/SelectItem'; 12 | import ButtonSwitch from '~/components/settings/ButtonSwitch' 13 | 14 | const orders = { 15 | 'title': { 16 | name: 'Title', 17 | icon: 'sort-alpha-asc', 18 | }, 19 | 'changed': { 20 | name: 'Recently Updated', 21 | icon: 'sort-amount-desc', 22 | }, 23 | 'newest': { 24 | name: 'Newest First', 25 | icon: 'sort-numeric-desc', 26 | }, 27 | 'oldest': { 28 | name: 'Oldest First', 29 | icon: 'sort-numeric-asc', 30 | }, 31 | } 32 | 33 | const PlaylistsSettings = () => { 34 | const insets = useSafeAreaInsets() 35 | const settings = React.useContext(SettingsContext) 36 | const setSettings = React.useContext(SetSettingsContext) 37 | const theme = React.useContext(ThemeContext) 38 | const [previewFavorited, setPreviewFavorited] = React.useState(settings.previewFavorited.toString()) 39 | 40 | React.useEffect(() => { 41 | if (settings.previewFavorited.toString() != previewFavorited) 42 | setPreviewFavorited(settings.previewFavorited.toString()) 43 | }, [settings.previewFavorited]) 44 | 45 | React.useEffect(() => { 46 | if (previewFavorited === '') return 47 | const number = parseInt(previewFavorited) 48 | if (number === settings.previewFavorited) return 49 | setSettings({ ...settings, previewFavorited: number }) 50 | }, [previewFavorited]) 51 | 52 | return ( 53 | 59 | 60 | 61 | Preview Favorited 62 | 63 | { 67 | if (parseInt(text) > 9) return 68 | setPreviewFavorited(text.replace(/[^0-9]/g, '')) 69 | }} 70 | inputMode="numeric" 71 | isLast={true} 72 | /> 73 | 74 | Number of songs to preview in favorited playlist (default: 3) 75 | 76 | Order Playlists 77 | 78 | { 79 | Object.keys(orders).map((name, index) => ( 80 | { 86 | setSettings({ ...settings, orderPlaylist: name }) 87 | }} 88 | /> 89 | )) 90 | } 91 | 92 | Playlist Page 93 | 94 | setSettings({ ...settings, reversePlaylist: !settings.reversePlaylist })} 98 | isLast={true} 99 | /> 100 | 101 | If enabled, recently added tracks will be shown first. 102 | 103 | 104 | ) 105 | } 106 | 107 | export default PlaylistsSettings; -------------------------------------------------------------------------------- /app/screens/Settings/Shares.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { View, Text, ScrollView, Platform, Share } from 'react-native' 3 | import { useSafeAreaInsets } from 'react-native-safe-area-context' 4 | 5 | import { ConfigContext } from '~/contexts/config' 6 | import { confirmAlert } from '~/utils/alert' 7 | import { ThemeContext } from '~/contexts/theme' 8 | import { useCachedAndApi, getApi } from '~/utils/api' 9 | import ButtonText from '~/components/settings/ButtonText' 10 | import Header from '~/components/Header' 11 | import mainStyles from '~/styles/main' 12 | import OptionsPopup from '~/components/popup/OptionsPopup' 13 | import settingStyles from '~/styles/settings' 14 | import TableItem from '~/components/settings/TableItem' 15 | 16 | const SharesSettings = () => { 17 | const insets = useSafeAreaInsets() 18 | const theme = React.useContext(ThemeContext) 19 | const refOption = React.useRef() 20 | const config = React.useContext(ConfigContext) 21 | const [indexOptions, setIndexOptions] = React.useState(-1) 22 | 23 | const [shares, refresh] = useCachedAndApi([], 'getShares', null, (json, setData) => { 24 | setData(json.shares?.share || []) 25 | }) 26 | 27 | return ( 28 | 32 | 33 | 34 | 35 | Shares 36 | 37 | { 38 | shares.length === 0 && ( 39 | 44 | ) 45 | } 46 | { 47 | shares.map((item, index) => { 48 | return ( 49 | { 56 | setIndexOptions(index) 57 | }} 58 | /> 59 | ) 60 | }) 61 | } 62 | 63 | 64 | 65 | { 68 | confirmAlert( 69 | 'Clear all shares', 70 | 'Are you sure you want to clear all shares?', () => { 71 | if (shares.length === 0) return 72 | const wait = shares?.map((item) => { 73 | return getApi(config, 'deleteShare', { id: item.id }) 74 | }) 75 | Promise.all(wait).then(() => { 76 | refresh() 77 | }) 78 | }) 79 | }} 80 | /> 81 | 82 | {/* Popups */} 83 | = 0} 86 | close={() => { 87 | setIndexOptions(-1) 88 | }} 89 | options={[ 90 | { 91 | name: 'Share', 92 | icon: 'share', 93 | onPress: () => { 94 | if (Platform.OS === 'web') navigator.clipboard.writeText(shares[indexOptions].url) 95 | else Share.share({ message: shares[indexOptions].url }) 96 | refOption.current.close() 97 | } 98 | }, 99 | { 100 | name: 'Delete', 101 | icon: 'trash-o', 102 | onPress: () => { 103 | getApi(config, 'deleteShare', { id: shares[indexOptions].id }) 104 | .then(() => { 105 | refresh() 106 | }) 107 | refOption.current.close() 108 | } 109 | } 110 | ]} 111 | /> 112 | 113 | 114 | ) 115 | } 116 | 117 | export default SharesSettings; -------------------------------------------------------------------------------- /app/screens/Settings/Theme.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Text, View, ScrollView } from 'react-native'; 3 | import { useSafeAreaInsets } from 'react-native-safe-area-context'; 4 | 5 | import { SettingsContext, SetSettingsContext } from '~/contexts/settings'; 6 | import { ThemeContext } from '~/contexts/theme'; 7 | import { themes, themesPlayer } from '~/contexts/theme'; 8 | import Header from '~/components/Header'; 9 | import mainStyles from '~/styles/main'; 10 | import settingStyles from '~/styles/settings'; 11 | import SelectItem from '~/components/settings/SelectItem'; 12 | 13 | const Theme = () => { 14 | const insets = useSafeAreaInsets() 15 | const settings = React.useContext(SettingsContext) 16 | const setSettings = React.useContext(SetSettingsContext) 17 | const theme = React.useContext(ThemeContext) 18 | 19 | return ( 20 | 24 | 25 | 26 | Theme 27 | 28 | { 29 | Object.keys(themes).map((themeName, index) => ( 30 | { 37 | setSettings({ ...settings, theme: themeName }) 38 | }} 39 | /> 40 | )) 41 | } 42 | 43 | Player Theme 44 | 45 | { 46 | Object.keys(themesPlayer).map((themeName, index) => ( 47 | { 54 | setSettings({ ...settings, themePlayer: themeName }) 55 | }} 56 | /> 57 | )) 58 | } 59 | 60 | 61 | 62 | ) 63 | } 64 | 65 | export default Theme; -------------------------------------------------------------------------------- /app/screens/ShowAll.native.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { View, Text, Pressable, FlatList, StyleSheet } from 'react-native'; 3 | import { useSafeAreaInsets } from 'react-native-safe-area-context'; 4 | 5 | import { ConfigContext } from '~/contexts/config'; 6 | import { getCachedAndApi } from '~/utils/api'; 7 | import { ThemeContext } from '~/contexts/theme'; 8 | import { urlCover } from '~/utils/api'; 9 | import { getPathByType, setListByType } from '~/contexts/settings'; 10 | import ImageError from '~/components/ImageError'; 11 | import Header from '~/components/Header'; 12 | import mainStyles from '~/styles/main'; 13 | import size from '~/styles/size'; 14 | 15 | 16 | const ShowAll = ({ navigation, route: { params: { type, query, title } } }) => { 17 | const insets = useSafeAreaInsets(); 18 | const config = React.useContext(ConfigContext); 19 | const theme = React.useContext(ThemeContext); 20 | const [list, setList] = React.useState([]); 21 | 22 | React.useEffect(() => { 23 | getList(); 24 | }, [type, query]) 25 | 26 | const getList = async () => { 27 | const path = getPathByType(type) 28 | let nquery = query ? query : '' 29 | 30 | if (type == 'album') nquery += '&size=' + 100 31 | getCachedAndApi(config, path, nquery, (json) => { 32 | setListByType(json, type, setList); 33 | }) 34 | } 35 | 36 | const onPress = (item) => { 37 | if (type === 'album') return navigation.navigate('Album', item) 38 | if (type === 'album_star') return navigation.navigate('Album', item) 39 | if (type === 'artist') return navigation.navigate('Artist', { id: item.id, name: item.name }) 40 | if (type === 'artist_all') return navigation.navigate('Artist', { id: item.id, name: item.name }) 41 | } 42 | 43 | const ItemComponent = React.memo(function Item({ item, index }) { 44 | return ( 45 | ([mainStyles.opacity({ pressed }), styles.album])} 47 | key={index} 48 | onPress={() => onPress(item)}> 49 | 56 | {item.name} 57 | {item.artist} 58 | 59 | ) 60 | }) 61 | 62 | return ( 63 | } 73 | ListHeaderComponent={() => } 74 | data={list} 75 | keyExtractor={(item, index) => index} 76 | renderItem={({ item, index }) => ( 77 | 78 | )} 79 | /> 80 | ); 81 | } 82 | 83 | const styles = StyleSheet.create({ 84 | album: { 85 | flex: 1, 86 | maxWidth: "50%", 87 | }, 88 | albumCover: (type) => ({ 89 | width: "100%", 90 | aspectRatio: 1, 91 | marginBottom: 6, 92 | borderRadius: ['artist', 'artist_all'].includes(type) ? size.radius.circle : 0, 93 | }), 94 | titleAlbum: (theme) => ({ 95 | color: theme.primaryText, 96 | fontSize: size.text.small, 97 | width: '100%', 98 | marginBottom: 3, 99 | marginTop: 3, 100 | }), 101 | artist: theme => ({ 102 | color: theme.secondaryText, 103 | fontSize: size.text.small, 104 | width: '100%', 105 | }), 106 | }) 107 | 108 | export default ShowAll; -------------------------------------------------------------------------------- /app/screens/ShowAll.web.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { View, Text, ScrollView, Pressable, StyleSheet } from 'react-native'; 3 | import { useSafeAreaInsets } from 'react-native-safe-area-context'; 4 | 5 | import { ConfigContext } from '~/contexts/config'; 6 | import { getCachedAndApi } from '~/utils/api'; 7 | import { ThemeContext } from '~/contexts/theme'; 8 | import { urlCover } from '~/utils/api'; 9 | import { getPathByType, setListByType } from '~/contexts/settings'; 10 | import ImageError from '~/components/ImageError'; 11 | import Header from '~/components/Header'; 12 | import mainStyles from '~/styles/main'; 13 | import size from '~/styles/size'; 14 | 15 | 16 | const ShowAll = ({ navigation, route: { params: { type, query, title } } }) => { 17 | const insets = useSafeAreaInsets(); 18 | const config = React.useContext(ConfigContext); 19 | const theme = React.useContext(ThemeContext); 20 | const [list, setList] = React.useState([]); 21 | 22 | React.useEffect(() => { 23 | getList(); 24 | }, [type, query]) 25 | 26 | const getList = async () => { 27 | const path = getPathByType(type) 28 | let nquery = query ? query : '' 29 | 30 | if (type == 'album') nquery += '&size=' + 100 31 | getCachedAndApi(config, path, nquery, (json) => { 32 | setListByType(json, type, setList); 33 | }) 34 | } 35 | 36 | const onPress = (item) => { 37 | if (type === 'album') return navigation.navigate('Album', item) 38 | if (type === 'album_star') return navigation.navigate('Album', item) 39 | if (type === 'artist') return navigation.navigate('Artist', { id: item.id, name: item.name }) 40 | if (type === 'artist_all') return navigation.navigate('Artist', { id: item.id, name: item.name }) 41 | } 42 | 43 | // I try to use FlatList instead of ScrollView but it glitched and numColumns can't be useState 44 | // in doc it says that Flatlist is not compatible with flexWrap 45 | return ( 46 | 51 | 52 | 62 | { 63 | list.map((item, index) => ( 64 | ([mainStyles.opacity({ pressed }), styles.album])} 66 | key={index} 67 | onPress={() => onPress(item)}> 68 | 75 | {item.name} 76 | {item.artist} 77 | 78 | ))} 79 | 80 | 81 | ); 82 | } 83 | 84 | const styles = StyleSheet.create({ 85 | album: { 86 | minWidth: size.image.large, 87 | maxWidth: 245, 88 | }, 89 | albumCover: (type) => ({ 90 | width: "100%", 91 | aspectRatio: 1, 92 | marginBottom: 6, 93 | borderRadius: ['artist', 'artist_all'].includes(type) ? size.radius.circle : 0, 94 | }), 95 | titleAlbum: (theme) => ({ 96 | color: theme.primaryText, 97 | fontSize: size.text.small, 98 | width: '100%', 99 | marginBottom: 3, 100 | marginTop: 3, 101 | }), 102 | artist: theme => ({ 103 | color: theme.secondaryText, 104 | fontSize: size.text.small, 105 | width: '100%', 106 | }), 107 | }) 108 | 109 | export default ShowAll; -------------------------------------------------------------------------------- /app/screens/Stacks.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createNativeStackNavigator } from '@react-navigation/native-stack'; 3 | 4 | import Home from '~/screens/tabs/Home'; 5 | import Playlists from '~/screens/tabs/Playlists'; 6 | import Search from '~/screens/tabs/Search'; 7 | import Settings from '~/screens/tabs/Settings'; 8 | 9 | import Album from '~/screens/Album'; 10 | import Artist from '~/screens/Artist'; 11 | import Favorited from '~/screens/Favorited'; 12 | import Genre from '~/screens/Genre'; 13 | import Playlist from '~/screens/Playlist'; 14 | import UpdateRadio from '~/screens/UpdateRadio'; 15 | import Connect from '~/screens/Settings/Connect'; 16 | import HomeSettings from '~/screens/Settings/Home'; 17 | import PlaylistsSettings from '~/screens/Settings/Playlists'; 18 | import CacheSettings from '~/screens/Settings/Cache'; 19 | import InformationsSettings from '~/screens/Settings/Informations'; 20 | import ThemeSettings from '~/screens/Settings/Theme'; 21 | import PlayerSettings from '~/screens/Settings/Player'; 22 | import SharesSettings from '~/screens/Settings/Shares'; 23 | import AddServer from '~/screens/Settings/AddServer'; 24 | import ShowAll from '~/screens/ShowAll'; 25 | import ArtistExplorer from '~/screens/ArtistExplorer'; 26 | 27 | import { ThemeContext } from '~/contexts/theme'; 28 | 29 | const Stack = createNativeStackNavigator(); 30 | 31 | export const HomeStack = () => { 32 | const theme = React.useContext(ThemeContext) 33 | 34 | return ( 35 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | ) 54 | } 55 | 56 | export const SearchStack = () => { 57 | const theme = React.useContext(ThemeContext) 58 | 59 | return ( 60 | 70 | 71 | 72 | 73 | 74 | 75 | ) 76 | } 77 | 78 | export const PlaylistsStack = () => { 79 | const theme = React.useContext(ThemeContext) 80 | 81 | return ( 82 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | ) 99 | } 100 | 101 | export const SettingsStack = () => { 102 | const theme = React.useContext(ThemeContext) 103 | 104 | return ( 105 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | ) 127 | } -------------------------------------------------------------------------------- /app/screens/UpdateRadio.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Text, View } from 'react-native'; 3 | import { useSafeAreaInsets } from 'react-native-safe-area-context'; 4 | 5 | import { ConfigContext } from '~/contexts/config'; 6 | import { getApi } from '~/utils/api'; 7 | import { ThemeContext } from '~/contexts/theme'; 8 | import OptionInput from '~/components/settings/OptionInput'; 9 | import Header from '~/components/Header'; 10 | import mainStyles from '~/styles/main'; 11 | import settingStyles from '~/styles/settings'; 12 | import ButtonText from '~/components/settings/ButtonText'; 13 | 14 | const UpdateRadio = ({ navigation, route: { params } }) => { 15 | const theme = React.useContext(ThemeContext) 16 | const insets = useSafeAreaInsets(); 17 | const config = React.useContext(ConfigContext) 18 | const [name, setName] = React.useState(''); 19 | const [streamUrl, setStreamUrl] = React.useState(''); 20 | const [homePageUrl, setHomePageUrl] = React.useState(''); 21 | const [error, setError] = React.useState(''); 22 | 23 | React.useEffect(() => { 24 | if (params?.name) setName(params.name) 25 | if (params?.streamUrl) setStreamUrl(params.streamUrl) 26 | if (params?.homePageUrl) setHomePageUrl(params.homePageUrl) 27 | }, []) 28 | 29 | const updateRadio = () => { 30 | if (!name || !streamUrl) return 31 | if (!params?.id) { 32 | getApi(config, 'createInternetRadioStation', { name, streamUrl, homepageUrl: homePageUrl, }) 33 | .then(() => { navigation.goBack() }) 34 | .catch((error) => { 35 | if (error.isApiError) setError(error.message) 36 | else setError('Failed to connect to server') 37 | }) 38 | } else { 39 | getApi(config, 'updateInternetRadioStation', { id: params.id, name, streamUrl, homepageUrl: homePageUrl, }) 40 | .then(() => { navigation.goBack() }) 41 | .catch((error) => { 42 | if (error.isApiError) setError(error.message) 43 | else setError('Failed to connect to server') 44 | }) 45 | } 46 | } 47 | 48 | return ( 49 | 53 | 54 | 55 | 64 | {error} 65 | 66 | 67 | 76 | 85 | 96 | 97 | 102 | 103 | 104 | ) 105 | } 106 | 107 | export default UpdateRadio; -------------------------------------------------------------------------------- /app/screens/tabs/Home.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Text, View, ScrollView, Animated, StyleSheet, Pressable } from 'react-native'; 3 | import { useSafeAreaInsets } from 'react-native-safe-area-context'; 4 | import { ConfigContext } from '~/contexts/config'; 5 | 6 | import { getApi, getApiNetworkFirst } from '~/utils/api'; 7 | import { playSong } from '~/utils/player'; 8 | import { SettingsContext } from '~/contexts/settings'; 9 | import { SongDispatchContext } from '~/contexts/song'; 10 | import { ThemeContext } from '~/contexts/theme'; 11 | import HorizontalList from '~/components/lists/HorizontalList'; 12 | import IconButton from '~/components/button/IconButton'; 13 | import mainStyles from '~/styles/main'; 14 | import size from '~/styles/size'; 15 | 16 | const Home = () => { 17 | const insets = useSafeAreaInsets(); 18 | const songDispatch = React.useContext(SongDispatchContext) 19 | const config = React.useContext(ConfigContext) 20 | const settings = React.useContext(SettingsContext) 21 | const theme = React.useContext(ThemeContext) 22 | const [statusRefresh, setStatusRefresh] = React.useState(); 23 | const [refresh, setRefresh] = React.useState(0); 24 | const rotationValue = React.useRef(new Animated.Value(0)).current; 25 | const rotation = rotationValue.interpolate({ 26 | inputRange: [0, 1], 27 | outputRange: ['0deg', '360deg'] 28 | }) 29 | 30 | const clickRandomSong = () => { 31 | getApiNetworkFirst(config, 'getRandomSongs', `size=50`) 32 | .then((json) => { 33 | playSong(config, songDispatch, json.randomSongs.song, 0) 34 | }) 35 | .catch(() => { }) 36 | } 37 | 38 | const forceRefresh = () => { 39 | setRefresh(refresh + 1) 40 | rotationValue.setValue(0) 41 | Animated.timing(rotationValue, { 42 | toValue: 1, 43 | duration: 1000, 44 | useNativeDriver: true, 45 | }).start() 46 | } 47 | 48 | const getStatusRefresh = () => { 49 | getApi(config, 'getScanStatus') 50 | .then((json) => { 51 | if (json.scanStatus.scanning) { 52 | setTimeout(() => { 53 | getStatusRefresh() 54 | }, 1000) 55 | setStatusRefresh(json.scanStatus) 56 | } else { 57 | forceRefresh() 58 | setStatusRefresh() 59 | } 60 | }) 61 | .catch(() => { }) 62 | } 63 | 64 | const refreshServer = () => { 65 | forceRefresh() 66 | getApi(config, 'startScan', 'fullScan=true') 67 | .then(() => { 68 | getStatusRefresh() 69 | }) 70 | .catch(() => { }) 71 | } 72 | 73 | return ( 74 | 78 | 79 | ([mainStyles.opacity({ pressed }), styles.boxRandom(theme)])} 81 | onPress={clickRandomSong}> 82 | Random Song 83 | 84 | {statusRefresh ? 85 | 87 | 88 | {statusRefresh.count}° 89 | 90 | : 91 | 92 | 101 | 102 | } 103 | 104 | {config?.url && settings?.homeOrder?.map((value, index) => 105 | 106 | )} 107 | 108 | ); 109 | } 110 | 111 | const styles = StyleSheet.create({ 112 | boxRandom: theme => ({ 113 | backgroundColor: theme.secondaryTouch, 114 | alignItems: 'center', 115 | padding: 7, 116 | paddingHorizontal: 15, 117 | justifyContent: 'center', 118 | borderRadius: size.radius.circle, 119 | }), 120 | textRandom: theme => ({ 121 | fontSize: 18, 122 | color: theme.innerTouch, 123 | fontWeight: 'bold', 124 | }), 125 | }) 126 | 127 | export default Home; -------------------------------------------------------------------------------- /app/screens/tabs/Settings.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import pkg from '~/../package.json'; 3 | import { Text, View, Image, ScrollView, Pressable, Linking } from 'react-native'; 4 | import { useSafeAreaInsets } from 'react-native-safe-area-context'; 5 | 6 | import { ConfigContext } from '~/contexts/config'; 7 | import { confirmAlert } from '~/utils/alert'; 8 | import { SetSettingsContext, defaultSettings, SettingsContext } from '~/contexts/settings'; 9 | import { SongDispatchContext } from '~/contexts/song'; 10 | import { ThemeContext } from '~/contexts/theme'; 11 | import ButtonMenu from '~/components/settings/ButtonMenu'; 12 | import ButtonSwitch from '~/components/settings/ButtonSwitch'; 13 | import mainStyles from '~/styles/main'; 14 | import Player from '~/utils/player'; 15 | import settingStyles from '~/styles/settings'; 16 | import size from '~/styles/size'; 17 | 18 | const Settings = ({ navigation }) => { 19 | const insets = useSafeAreaInsets(); 20 | const config = React.useContext(ConfigContext) 21 | const theme = React.useContext(ThemeContext) 22 | const setSettings = React.useContext(SetSettingsContext) 23 | const setting = React.useContext(SettingsContext) 24 | const songDispatch = React.useContext(SongDispatchContext) 25 | 26 | return ( 27 | 34 | 35 | Player.tuktuktuk(songDispatch)} 37 | style={({ pressed }) => ([mainStyles.opacity({ pressed }), { 38 | flexDirection: 'row', 39 | alignItems: 'center', 40 | width: '100%', 41 | paddingVertical: 10, 42 | }])}> 43 | 47 | 48 | Castafiore 49 | Version {pkg.version} 50 | 51 | 52 | 53 | 54 | navigation.navigate('Connect')} 59 | isLast={true} 60 | /> 61 | 62 | 63 | 64 | setSettings({ ...setting, isDesktop: !setting.isDesktop })} 69 | isLast={true} /> 70 | 71 | 72 | 73 | navigation.navigate('Settings/Home')} 77 | /> 78 | navigation.navigate('Settings/Playlists')} 82 | /> 83 | navigation.navigate('Settings/Player')} 87 | /> 88 | navigation.navigate('Settings/Theme')} 92 | /> 93 | navigation.navigate('Settings/Cache')} 97 | isLast={true} 98 | /> 99 | 100 | 101 | {config.query && ( 102 | 103 | navigation.navigate('Settings/Shares')} 107 | /> 108 | navigation.navigate('Settings/Informations')} 112 | isLast={true} 113 | /> 114 | 115 | )} 116 | 117 | 118 | Linking.openURL('https://github.com/sawyerf/Castafiore')} 122 | isLast={true} 123 | /> 124 | 125 | 126 | 127 | { 131 | confirmAlert( 132 | 'Reset Settings', 133 | 'Are you sure you want to reset all settings?', 134 | () => setSettings({ 135 | ...defaultSettings, 136 | servers: setting.servers 137 | }) 138 | ) 139 | }} 140 | isLast={true} 141 | /> 142 | 143 | 144 | 145 | ) 146 | } 147 | 148 | export default Settings; -------------------------------------------------------------------------------- /app/services/servicePlayback.js: -------------------------------------------------------------------------------- 1 | import TrackPlayer, { Event } from "react-native-track-player" 2 | 3 | import Player from "~/utils/player" 4 | import { getApi } from "~/utils/api" 5 | 6 | module.exports = async () => { 7 | TrackPlayer.addEventListener(Event.RemotePlay, () => Player.resumeSong()) 8 | TrackPlayer.addEventListener(Event.RemotePause, () => Player.pauseSong()) 9 | TrackPlayer.addEventListener(Event.RemoteNext, () => TrackPlayer.skipToNext()) 10 | TrackPlayer.addEventListener(Event.RemotePrevious, () => TrackPlayer.skipToPrevious()) 11 | TrackPlayer.addEventListener(Event.RemoteSeek, (event) => Player.setPosition(event.position)) 12 | TrackPlayer.addEventListener(Event.PlaybackActiveTrackChanged, async (event) => { 13 | if (event.track?.id === 'tuktuktukend') { 14 | await TrackPlayer.remove([0, 1]) 15 | } else { 16 | if (event.lastTrack) { 17 | if (event.lastPosition >= event.lastTrack.duration - 1) { 18 | getApi(event.lastTrack.config, 'scrobble', `id=${event.lastTrack.id}&submission=true`) 19 | .catch(() => { }) 20 | } 21 | } 22 | if (event.track) { 23 | getApi(event.track.config, 'scrobble', `id=${event.track.id}&submission=false`) 24 | .catch(() => { }) 25 | } 26 | } 27 | }) 28 | } 29 | -------------------------------------------------------------------------------- /app/services/serviceWorkerRegistration.android.js: -------------------------------------------------------------------------------- 1 | export function register(_config) {} 2 | 3 | export function unregister() {} 4 | 5 | export const clearCache = async () => {}; 6 | 7 | export const clearAllCaches = async () => {} -------------------------------------------------------------------------------- /app/services/serviceWorkerRegistration.ios.js: -------------------------------------------------------------------------------- 1 | export function register(_config) {} 2 | 3 | export function unregister() {} 4 | 5 | export const clearCache = async () => {}; 6 | 7 | export const clearAllCaches = async () => {} -------------------------------------------------------------------------------- /app/services/serviceWorkerRegistration.web.js: -------------------------------------------------------------------------------- 1 | const isLocalhost = Boolean( 2 | window.location.hostname === "localhost" || 3 | window.location.hostname === "[::1]" || 4 | window.location.hostname.match( 5 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 6 | ) 7 | ); 8 | 9 | export const register = (config) => { 10 | const isEnvProduction = process.env.NODE_ENV === "production"; 11 | if (isEnvProduction && "serviceWorker" in navigator) { 12 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); 13 | if (publicUrl.origin !== window.location.origin) { 14 | return; 15 | } 16 | 17 | window.addEventListener("load", () => { 18 | const swUrl = `./serviceWorker.js`; 19 | 20 | if (isLocalhost) { 21 | checkValidServiceWorker(swUrl, config); 22 | 23 | navigator.serviceWorker.ready.then(() => { 24 | console.log('serviceWorker ready') 25 | }); 26 | } else { 27 | registerValidSW(swUrl, config); 28 | } 29 | }); 30 | } 31 | } 32 | 33 | const registerValidSW = (swUrl, config) => { 34 | navigator.serviceWorker 35 | .register(swUrl) 36 | .then((registration) => { 37 | registration.onupdatefound = () => { 38 | const installingWorker = registration.installing; 39 | if (installingWorker == null) { 40 | return; 41 | } 42 | installingWorker.onstatechange = () => { 43 | if (installingWorker.state === "installed") { 44 | if (navigator.serviceWorker.controller) { 45 | if (config && config.onUpdate) { 46 | config.onUpdate(registration); 47 | } 48 | } else { 49 | console.log("Content is cached for offline use."); 50 | 51 | if (config && config.onSuccess) { 52 | config.onSuccess(registration); 53 | } 54 | } 55 | } 56 | }; 57 | }; 58 | }) 59 | .catch((error) => { 60 | console.error("Error during service worker registration:", error); 61 | }); 62 | } 63 | 64 | const checkValidServiceWorker = (swUrl, config) => { 65 | fetch(swUrl, { 66 | headers: { "Service-Worker": "script" }, 67 | }) 68 | .then((response) => { 69 | const contentType = response.headers.get("content-type"); 70 | if ( 71 | response.status === 404 || 72 | (contentType != null && contentType.indexOf("javascript") === -1) 73 | ) { 74 | navigator.serviceWorker.ready.then((registration) => { 75 | registration.unregister().then(() => { 76 | window.location.reload(); 77 | }); 78 | }); 79 | } else { 80 | registerValidSW(swUrl, config); 81 | } 82 | }) 83 | .catch(() => { 84 | console.log("No internet connection found. App is running in offline mode."); 85 | }); 86 | } 87 | 88 | export const unregister = () => { 89 | if ("serviceWorker" in navigator) { 90 | navigator.serviceWorker.ready 91 | .then((registration) => { 92 | registration.unregister(); 93 | }) 94 | .catch((error) => { 95 | console.error(error.message); 96 | }); 97 | } 98 | } 99 | 100 | export const clearCache = async () => { 101 | caches.keys().then((names) => { 102 | ['coverArt', 'api'].forEach((key) => { 103 | if (names.includes(key)) { 104 | caches.delete(key); 105 | } 106 | }) 107 | }); 108 | } 109 | 110 | export const clearAllCaches = async () => { 111 | caches.keys().then((names) => { 112 | names.forEach((key) => { 113 | caches.delete(key); 114 | }) 115 | }); 116 | } -------------------------------------------------------------------------------- /app/styles/main.js: -------------------------------------------------------------------------------- 1 | import { Platform, StyleSheet } from "react-native"; 2 | import size from "~/styles/size"; 3 | 4 | export default StyleSheet.create({ 5 | mainContainer: (theme) => ({ 6 | flex: 1, 7 | backgroundColor: theme.primaryBack, 8 | }), 9 | contentMainContainer: (insets, statusBar = true) => ({ 10 | paddingTop: statusBar ? insets.top : 0, 11 | paddingBottom: 80 + Platform.select({ web: 0, android: insets.bottom }), 12 | paddingStart: insets.left, 13 | paddingEnd: insets.right, 14 | }), 15 | mainTitle: theme => ({ 16 | color: theme.primaryText, 17 | fontSize: size.title.medium, 18 | fontWeight: 'bold', 19 | margin: 20, 20 | marginTop: 30 21 | }), 22 | subTitle: theme => ({ 23 | color: theme.primaryText, 24 | fontSize: size.title.small, 25 | fontWeight: 'bold', 26 | }), 27 | titleSection: theme => ({ 28 | color: theme.primaryText, 29 | fontSize: size.title.small, 30 | fontWeight: 'bold', 31 | margin: 20, 32 | marginTop: 25, 33 | marginBottom: 10 34 | }), 35 | stdVerticalMargin: { 36 | marginStart: 20, 37 | marginEnd: 20, 38 | }, 39 | button: { 40 | flex: 1, 41 | height: 50, 42 | alignItems: "center", 43 | justifyContent: "center", 44 | }, 45 | coverSmall: theme => ({ 46 | height: size.image.small, 47 | width: size.image.small, 48 | // marginStart: 10, 49 | borderRadius: 4, 50 | backgroundColor: theme.secondaryBack, 51 | }), 52 | icon: { 53 | width: size.image.small, 54 | height: size.image.small, 55 | borderRadius: 10, 56 | marginEnd: 10 57 | }, 58 | opacity: ({ pressed }) => ({ 59 | opacity: pressed ? 0.5 : 1, 60 | }), 61 | }) -------------------------------------------------------------------------------- /app/styles/pres.js: -------------------------------------------------------------------------------- 1 | import { StyleSheet } from 'react-native'; 2 | import size from '~/styles/size'; 3 | 4 | export default StyleSheet.create({ 5 | cover: { 6 | width: "100%", 7 | height: 300, 8 | }, 9 | title: theme => ({ 10 | color: theme.primaryText, 11 | fontSize: size.title.medium, 12 | fontWeight: 'bold', 13 | margin: 20, 14 | marginBottom: 0, 15 | marginTop: 13, 16 | }), 17 | subTitle: theme => ({ 18 | color: theme.secondaryText, 19 | fontSize: size.text.large, 20 | marginBottom: 30, 21 | marginStart: 20, 22 | }), 23 | button: { 24 | padding: 20, 25 | justifyContent: 'start', 26 | }, 27 | headerContainer: { 28 | flexDirection: 'row', 29 | width: '100%', 30 | maxWidth: '100%', 31 | }, 32 | }) -------------------------------------------------------------------------------- /app/styles/settings.js: -------------------------------------------------------------------------------- 1 | import { StyleSheet } from 'react-native' 2 | import size from '~/styles/size'; 3 | 4 | export default StyleSheet.create({ 5 | titleContainer: theme => ({ 6 | width: '100%', 7 | fontSize: 12, 8 | textTransform: 'uppercase', 9 | fontWeight: 'bold', 10 | color: theme.secondaryText, 11 | marginBottom: 5, 12 | marginStart: 10, 13 | }), 14 | optionsContainer: theme => ({ 15 | flexDirection: 'column', 16 | width: '100%', 17 | paddingVertical: 1, 18 | paddingHorizontal: 17, 19 | backgroundColor: theme.secondaryBack, 20 | borderRadius: 10, 21 | marginBottom: 30, 22 | }), 23 | description: theme => ({ 24 | color: theme.secondaryText, 25 | fontSize: size.text.small, 26 | marginStart: 5, 27 | marginBottom: 20, 28 | width: '100%', 29 | textAlign: 'left' 30 | }), 31 | contentMainContainer: { 32 | maxWidth: 800, 33 | width: '100%', 34 | alignItems: 'center', 35 | justifyContent: 'center', 36 | paddingHorizontal: 20, 37 | paddingStart: 20, 38 | paddingEnd: 20, 39 | alignSelf: 'center', 40 | }, 41 | optionItem: (theme, isLast) => ({ 42 | width: '100%', 43 | height: 50, 44 | paddingEnd: 5, 45 | alignItems: 'center', 46 | borderBottomColor: theme.secondaryText, 47 | borderBottomWidth: isLast ? 0 : .5, 48 | flexDirection: 'row', 49 | }), 50 | primaryText: (theme, style = { flex: 1 }) => ({ 51 | color: theme.primaryText, 52 | fontSize: size.text.medium, 53 | marginEnd: 10, 54 | ...style, 55 | }), 56 | }) -------------------------------------------------------------------------------- /app/styles/size.js: -------------------------------------------------------------------------------- 1 | export default { 2 | image: { 3 | tiny: 40, 4 | player: 40, 5 | small: 50, // example: album cover in song list 6 | medium: 100, // example: album cover in album list 7 | large: 160, // example: album cover in album list 8 | }, 9 | title: { 10 | small: 25, 11 | medium: 30, 12 | large: 40, 13 | }, 14 | text: { 15 | small: 14, 16 | medium: 16, 17 | large: 20, 18 | }, 19 | radius: { 20 | circle: 999, 21 | }, 22 | icon: { 23 | tiny: 20, 24 | small: 23, 25 | medium: 25, 26 | large: 30, 27 | } 28 | } -------------------------------------------------------------------------------- /app/utils/alert.js: -------------------------------------------------------------------------------- 1 | import { Platform, Alert } from 'react-native' 2 | 3 | export const confirmAlert = (title, message, confirmCallback, cancelCallBack = () => { }) => { 4 | if (Platform.OS === 'web') { 5 | const result = window.confirm(message) 6 | if (result) confirmCallback() 7 | else cancelCallBack() 8 | } else { 9 | Alert.alert( 10 | title, 11 | message, 12 | [ 13 | { text: 'Cancel', onPress: cancelCallBack, style: 'cancel' }, 14 | { text: 'OK', onPress: confirmCallback } 15 | ] 16 | ) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /app/utils/cache.native.js: -------------------------------------------------------------------------------- 1 | import AsyncStorage from '@react-native-async-storage/async-storage' 2 | 3 | export const getCache = async (_cacheName, _key) => { 4 | return null 5 | } 6 | 7 | export const clearCache = async () => { 8 | return null 9 | } 10 | 11 | export const getStatCache = async () => { 12 | return null 13 | } 14 | 15 | export const getJsonCache = async (_cacheName, key) => { 16 | const json = await AsyncStorage.getItem(key) 17 | return json ? JSON.parse(json) : null 18 | } 19 | 20 | export const setJsonCache = async (_cacheName, key, json) => { 21 | if (!json) return 22 | await AsyncStorage.setItem(key, JSON.stringify(json)) 23 | } -------------------------------------------------------------------------------- /app/utils/cache.web.js: -------------------------------------------------------------------------------- 1 | export const getCache = async (cacheName, key) => { 2 | const caches = await window.caches.open(cacheName) 3 | if (!caches) return null 4 | return await caches.match(key) 5 | } 6 | 7 | export const clearCache = async () => { 8 | const keys = [ 9 | 'api', 10 | 'coverArt', 11 | 'images', 12 | 'lyrics', 13 | 'song', 14 | ] 15 | keys.forEach(async (key) => { 16 | await window.caches.delete(key) 17 | }) 18 | } 19 | 20 | export const getStatCache = async () => { 21 | const caches = await window.caches.keys() 22 | const stats = [] 23 | for (const name of caches) { 24 | const cache = await window.caches.open(name) 25 | const keys = await cache.keys() 26 | stats.push({ name, count: keys.length }) 27 | } 28 | return stats.sort((a, b) => a.name.localeCompare(b.name)) 29 | } 30 | 31 | export const getJsonCache = async (cacheName, url) => { 32 | const cache = await getCache(cacheName, url) 33 | if (!cache) return null 34 | const json = await cache.json() 35 | if (!json) return null 36 | return json['subsonic-response'] 37 | } 38 | 39 | export const setJsonCache = async (_cacheName, _key, _json) => { 40 | // Service worker already do this 41 | } -------------------------------------------------------------------------------- /app/utils/lrc.js: -------------------------------------------------------------------------------- 1 | export const parseLrc = (lrc) => { 2 | const lines = lrc.split('\n') 3 | const lyrics = [] 4 | for (let i = 0; i < lines.length; i++) { 5 | const line = lines[i] 6 | const time = line.match(/\[(\d{2}):(\d{2})\.(\d{2})\]/) 7 | if (time) { 8 | const minutes = parseInt(time[1]) 9 | const seconds = parseInt(time[2]) 10 | const milliseconds = parseInt(time[3]) 11 | const text = line.replace(/\[(\d{2}):(\d{2})\.(\d{2})\]/, '').trim() 12 | lyrics.push({ 13 | time: minutes * 60 + seconds + milliseconds / 100, 14 | text 15 | }) 16 | } 17 | } 18 | return lyrics 19 | } -------------------------------------------------------------------------------- /app/utils/theme.js: -------------------------------------------------------------------------------- 1 | export default { 2 | primaryBack: '#121212', 3 | secondaryBack: '#1e1e1e', 4 | primaryText: '#f5f5dc', 5 | secondaryText: 'gray', 6 | primaryTouch: '#cd1921', 7 | secondaryTouch: '#891116', 8 | 9 | playerBackground: '#1e1e1e', 10 | playerPrimaryText: '#f5f5dc', 11 | playerSecondaryText: 'gray', 12 | playerButton: '#cd1921' 13 | } -------------------------------------------------------------------------------- /app/utils/tools.js: -------------------------------------------------------------------------------- 1 | export const shuffle = (array) => { 2 | return array.map(value => ({ value, sort: Math.random() })) 3 | .sort((a, b) => a.sort - b.sort) 4 | .map(({ value }) => value) 5 | } -------------------------------------------------------------------------------- /app/utils/useKeyboardIsOpen.android.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Keyboard, Platform } from 'react-native' 3 | 4 | const useKeyboardIsOpen = () => { 5 | const [isKeyboardOpen, setIsKeyboardOpen] = React.useState(false) 6 | 7 | React.useEffect(() => { 8 | const keyboardDidShowListener = Keyboard.addListener('keyboardDidShow', () => { 9 | setIsKeyboardOpen(Platform.OS === 'android') 10 | }) 11 | const keyboardDidHideListener = Keyboard.addListener('keyboardDidHide', () => { 12 | setIsKeyboardOpen(false) 13 | }) 14 | 15 | return () => { 16 | keyboardDidShowListener.remove() 17 | keyboardDidHideListener.remove() 18 | } 19 | }, []) 20 | 21 | return isKeyboardOpen 22 | } 23 | 24 | export default useKeyboardIsOpen -------------------------------------------------------------------------------- /app/utils/useKeyboardIsOpen.web.js: -------------------------------------------------------------------------------- 1 | const useKeyboardIsOpen = () => { 2 | return false 3 | } 4 | 5 | export default useKeyboardIsOpen -------------------------------------------------------------------------------- /assets/foreground-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sawyerf/Castafiore/c2a2479325ed4472f73af92a87038c45b2a74ef3/assets/foreground-icon.png -------------------------------------------------------------------------------- /assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sawyerf/Castafiore/c2a2479325ed4472f73af92a87038c45b2a74ef3/assets/icon.png -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = function (api) { 2 | api.cache(true); 3 | return { 4 | presets: ['babel-preset-expo'], 5 | plugins: [ 6 | [ 7 | 'babel-plugin-root-import', 8 | { 9 | rootPathPrefix: '~/', 10 | rootPathSuffix: './app', 11 | }, 12 | ], 13 | ], 14 | }; 15 | }; 16 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | android-builder: 3 | image: openjdk:11-jdk 4 | container_name: android-builder 5 | working_dir: /app 6 | volumes: 7 | - .:/app 8 | build: 9 | context: . 10 | dockerfile: Dockerfile 11 | ports: 12 | - "8081:8081" 13 | - "19006:19006" 14 | stdin_open: true 15 | tty: true 16 | networks: 17 | - builder_network 18 | 19 | networks: 20 | builder_network: 21 | driver: bridge -------------------------------------------------------------------------------- /eas.json: -------------------------------------------------------------------------------- 1 | { 2 | "cli": { 3 | "version": ">= 14.2.0", 4 | "appVersionSource": "remote" 5 | }, 6 | "build": { 7 | "development": { 8 | "developmentClient": true, 9 | "distribution": "internal" 10 | }, 11 | "google": { 12 | "autoIncrement": true, 13 | "android": { 14 | "buildType": "app-bundle" 15 | } 16 | }, 17 | "production": { 18 | "autoIncrement": true, 19 | "android": { 20 | "buildType": "apk" 21 | } 22 | } 23 | }, 24 | "submit": { 25 | "production": {} 26 | } 27 | } -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import globals from "globals"; 2 | import pluginJs from "@eslint/js"; 3 | import pluginReact from "eslint-plugin-react"; 4 | 5 | /** @type {import('eslint').Linter.Config[]} */ 6 | export default [ 7 | pluginJs.configs.recommended, 8 | pluginReact.configs.flat.recommended, 9 | { 10 | languageOptions: { 11 | globals: { 12 | ...globals.browser, 13 | ...globals.node, 14 | } 15 | }, 16 | }, 17 | { 18 | files: ["app/**/*.js", "index.js", "App.js"], 19 | languageOptions: { globals: globals.browser }, 20 | settings: { 21 | react: { 22 | version: "detect" 23 | } 24 | }, 25 | rules: { 26 | "react/prop-types": "off", 27 | // "react/react-in-jsx-scope": "off", 28 | "no-unused-vars": ["error", { argsIgnorePattern: "^_" }], 29 | }, 30 | } 31 | ]; -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import { registerRootComponent } from 'expo'; 2 | 3 | import App from './App'; 4 | import { initService } from '~/utils/player'; 5 | 6 | registerRootComponent(App); 7 | 8 | initService() -------------------------------------------------------------------------------- /index.web.js: -------------------------------------------------------------------------------- 1 | import "@expo/metro-runtime"; 2 | import { registerRootComponent } from 'expo'; 3 | 4 | import App from './App'; 5 | import { initService } from '~/utils/player'; 6 | 7 | registerRootComponent(App); 8 | 9 | initService() -------------------------------------------------------------------------------- /metro.config.js: -------------------------------------------------------------------------------- 1 | const { getDefaultConfig } = require('expo/metro-config'); 2 | 3 | const config = getDefaultConfig(__dirname); 4 | 5 | module.exports = config; 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "castafiore", 3 | "version": "2025.05.30", 4 | "main": "index.js", 5 | "homepage": ".", 6 | "scripts": { 7 | "export:android": "eas build --platform android --profile production --local", 8 | "export:google": "eas build --platform android --profile google --local", 9 | "export:dev": "eas build --platform android --profile development --local", 10 | "android": "expo start --android --dev-client", 11 | "web": "cross-env PLATFORM=web expo start --web", 12 | "export:web": "cross-env PLATFORM=web expo export -p web && workbox generateSW workbox-config.js", 13 | "eslint": "eslint app index.js App.js -c eslint.config.mjs" 14 | }, 15 | "dependencies": { 16 | "@react-native-async-storage/async-storage": "1.23.1", 17 | "@react-navigation/bottom-tabs": "^7.3.13", 18 | "@react-navigation/native": "^7.1.9", 19 | "@react-navigation/native-stack": "^7.3.13", 20 | "expo": "^52.0.0", 21 | "expo-build-properties": "~0.13.3", 22 | "expo-dev-client": "~5.0.20", 23 | "md5": "^2.3.0", 24 | "react": "18.3.1", 25 | "react-dom": "18.3.1", 26 | "react-native": "0.76.9", 27 | "react-native-safe-area-context": "4.12.0", 28 | "react-native-screens": "~4.4.0", 29 | "react-native-track-player": "^4.1.1", 30 | "react-native-vector-icons": "^10.2.0", 31 | "react-native-web": "~0.19.13", 32 | "expo-navigation-bar": "~4.0.9" 33 | }, 34 | "devDependencies": { 35 | "@babel/core": "^7.20.0", 36 | "@eslint/js": "^9.17.0", 37 | "@expo/metro-runtime": "^5.0.4", 38 | "babel-plugin-root-import": "^6.6.0", 39 | "cross-env": "^7.0.3", 40 | "eslint": "^9.17.0", 41 | "eslint-plugin-react": "^7.37.3", 42 | "globals": "^15.14.0", 43 | "workbox-cli": "^7.3.0" 44 | }, 45 | "private": true 46 | } 47 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 9 | %WEB_TITLE% 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 83 | 84 | 85 | 86 | 90 | 91 | 100 | 107 | Oh no! It looks like JavaScript is not enabled in your browser. 108 | 109 | 120 | Reload 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Castafiore", 3 | "short_name": "Castafiore", 4 | "description": "Mobile app for navidrome", 5 | "lang": "en", 6 | "display": "standalone", 7 | "orientation": "portrait", 8 | "background_color": "#660000", 9 | "theme_color": "#121212", 10 | "start_url": "./index.html", 11 | "icons": [ 12 | { 13 | "src": "./pwa/icon.svg", 14 | "sizes": "any", 15 | "type": "image/svg+xml", 16 | "purpose": "any" 17 | }, 18 | { 19 | "src": "./pwa/adaptative-icon.png", 20 | "sizes": "1024x1024", 21 | "type": "image/png", 22 | "purpose": "maskable" 23 | } 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /public/pwa/adaptative-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sawyerf/Castafiore/c2a2479325ed4472f73af92a87038c45b2a74ef3/public/pwa/adaptative-icon.png -------------------------------------------------------------------------------- /public/pwa/apple-touch-icon-180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sawyerf/Castafiore/c2a2479325ed4472f73af92a87038c45b2a74ef3/public/pwa/apple-touch-icon-180.png -------------------------------------------------------------------------------- /workbox-config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | globDirectory: './dist/', 3 | swDest: 'dist/serviceWorker.js', 4 | globPatterns: ["**/*.{html,js,css,png,jpg,jpeg,gif,svg,json,woff2,woff,eot,ttf}",], 5 | dontCacheBustURLsMatching: /\.[0-9a-f]{8}\./, 6 | globIgnores: [ 7 | 'asset-manifest.json$', 8 | '**/*.map$', 9 | '**/LICENSE', 10 | '**/*.js.gz$', 11 | '**/(apple-touch-startup-image|chrome-icon|apple-touch-icon).*.png$', 12 | ], 13 | maximumFileSizeToCacheInBytes: 10 * 1024 * 1024, 14 | runtimeCaching: [ 15 | { 16 | urlPattern: ({ url }) => url.origin === self.location.origin && url.pathname.endsWith('.png'), 17 | handler: 'CacheFirst', 18 | options: { 19 | cacheName: 'images', 20 | expiration: { 21 | maxAgeSeconds: 60 * 60 * 24 * 7, 22 | }, 23 | }, 24 | }, 25 | { 26 | urlPattern: ({ url }) => url.pathname.match(/\/rest\/getCoverArt$/), 27 | handler: 'StaleWhileRevalidate', 28 | options: { 29 | cacheName: 'coverArt', 30 | }, 31 | }, 32 | { 33 | urlPattern: ({ url }) => url.pathname.match(/\/rest\/(getAlbumList|getAlbum|favorited|getAlbum|getStarred|getPlaylists|getArtist|getPlaylist|getTopSongs|getArtistInfo|getRandomSongs|search2)$/), 34 | handler: 'NetworkFirst', 35 | options: { 36 | cacheName: 'api', 37 | }, 38 | }, 39 | { 40 | urlPattern: ({ url }) => url.pathname.match(/\/rest\/getSimilarSongs$/), 41 | handler: 'StaleWhileRevalidate', 42 | options: { 43 | cacheName: 'apiLongResponse', 44 | }, 45 | }, 46 | { 47 | urlPattern: ({ url }) => url.pathname.match(/\/rest\/stream$/), 48 | handler: 'CacheFirst', 49 | options: { 50 | cacheName: 'song', 51 | }, 52 | }, 53 | { 54 | urlPattern: ({ url }) => url.hostname === 'lrclib.net' || url.pathname.match(/\/rest\/getLyricsBySongId$/), 55 | handler: 'CacheFirst', 56 | options: { 57 | cacheName: 'lyrics', 58 | }, 59 | }, 60 | ], 61 | }; --------------------------------------------------------------------------------
Oh no! It looks like JavaScript is not enabled in your browser.
109 | 120 | Reload 121 | 122 |