From 27754bd3c323cd4c06fd538497c18c6cfdc7bd07 Mon Sep 17 00:00:00 2001
From: austinried <4966622+austinried@users.noreply.github.com>
Date: Thu, 28 Apr 2022 15:41:45 +0900
Subject: [PATCH] minor reorg/cleanup
---
{assets => .assets}/f-droid-badge.png | Bin
{assets => .assets}/github-badge.png | Bin
{assets => .assets}/google-play-badge.png | Bin
{assets => .assets}/header.png | Bin
.bundle/config | 2 -
README.md | 19 +-
app/App.tsx | 6 +-
...x => GradientBackgroundHeaderFlatList.tsx} | 14 +-
app/components/GradientFlatList.tsx | 8 +-
...ground.tsx => GradientImageBackground.tsx} | 6 +-
app/components/GradientImageFlatList.tsx | 15 +
...llView.tsx => GradientImageScrollView.tsx} | 8 +-
app/components/ImageGradientFlatList.tsx | 13 -
app/hooks/fetch.ts | 315 ------------------
app/hooks/query.ts | 9 +-
app/hooks/settings.ts | 6 +-
app/hooks/trackplayer.ts | 8 +-
app/hooks/useClient.ts | 15 +
app/{ => i18n}/i18n.ts | 0
app/query/fetch/api.ts | 174 ++++++++++
app/query/fetch/file.ts | 132 ++++++++
app/{ => query}/queryClient.ts | 0
app/{hooks => query}/queryKeys.ts | 0
app/screens/NowPlayingView.tsx | 4 +-
app/screens/SongListView.tsx | 4 +-
.../QueueEvents.ts} | 8 +-
.../service.ts} | 15 +-
app/util/{fs.ts => cacheDir.ts} | 4 +-
index.js | 4 +-
29 files changed, 403 insertions(+), 386 deletions(-)
rename {assets => .assets}/f-droid-badge.png (100%)
rename {assets => .assets}/github-badge.png (100%)
rename {assets => .assets}/google-play-badge.png (100%)
rename {assets => .assets}/header.png (100%)
delete mode 100644 .bundle/config
rename app/components/{BackgroundHeaderFlatList.tsx => GradientBackgroundHeaderFlatList.tsx} (70%)
rename app/components/{ImageGradientBackground.tsx => GradientImageBackground.tsx} (93%)
create mode 100644 app/components/GradientImageFlatList.tsx
rename app/components/{ImageGradientScrollView.tsx => GradientImageScrollView.tsx} (71%)
delete mode 100644 app/components/ImageGradientFlatList.tsx
delete mode 100644 app/hooks/fetch.ts
create mode 100644 app/hooks/useClient.ts
rename app/{ => i18n}/i18n.ts (100%)
create mode 100644 app/query/fetch/api.ts
create mode 100644 app/query/fetch/file.ts
rename app/{ => query}/queryClient.ts (100%)
rename app/{hooks => query}/queryKeys.ts (100%)
rename app/{queueservice.ts => trackplayer/QueueEvents.ts} (75%)
rename app/{playbackservice.ts => trackplayer/service.ts} (94%)
rename app/util/{fs.ts => cacheDir.ts} (77%)
diff --git a/assets/f-droid-badge.png b/.assets/f-droid-badge.png
similarity index 100%
rename from assets/f-droid-badge.png
rename to .assets/f-droid-badge.png
diff --git a/assets/github-badge.png b/.assets/github-badge.png
similarity index 100%
rename from assets/github-badge.png
rename to .assets/github-badge.png
diff --git a/assets/google-play-badge.png b/.assets/google-play-badge.png
similarity index 100%
rename from assets/google-play-badge.png
rename to .assets/google-play-badge.png
diff --git a/assets/header.png b/.assets/header.png
similarity index 100%
rename from assets/header.png
rename to .assets/header.png
diff --git a/.bundle/config b/.bundle/config
deleted file mode 100644
index 848943b..0000000
--- a/.bundle/config
+++ /dev/null
@@ -1,2 +0,0 @@
-BUNDLE_PATH: "vendor/bundle"
-BUNDLE_FORCE_RUBY_PLATFORM: 1
diff --git a/README.md b/README.md
index 7757a22..e095d02 100644
--- a/README.md
+++ b/README.md
@@ -1,11 +1,13 @@
-
+
-#
-Subtracks is an Android open source music streaming app for [Subsonic-API-compatible](http://www.subsonic.org/pages/api.jsp) servers ([Subsonic](http://www.subsonic.org/pages/index.jsp), [Navidrome](https://www.navidrome.org/), [Airsonic](https://airsonic.github.io/), and more). It's designed to give you clean and convenient access to your music in the style of modern media players.
+#
+
+Subtracks is an Android open source music streaming app for [Subsonic-compatible](http://www.subsonic.org/pages/api.jsp) servers ([Subsonic](http://www.subsonic.org/pages/index.jsp), [Navidrome](https://www.navidrome.org/), [Airsonic](https://airsonic.github.io/), and more). It's designed to give you clean and convenient access to your music in the style of modern media players.
[](https://hosted.weblate.org/engage/subtracks/)   
# Screenshots
+
@@ -14,15 +16,17 @@ Subtracks is an Android open source music streaming app for [Subsonic-API-compat
# Download
+
-
-
-
+
+
+
> :warning: Note: each download source above is signed with a different key, so you cannot switch between them without first uninstalling and then re-installing the app.
# Features
+
- Album and artist art display by default (full-res in detail/now playing views)
- Gapless playback
- Mulitple server support
@@ -34,6 +38,7 @@ Subtracks is an Android open source music streaming app for [Subsonic-API-compat
- Long-press for context menu shortcuts
# Coming Soon™
+
- Offline support
- Customizable home screen categories
- Browse by folder support (currently only browses by tags)
@@ -46,9 +51,11 @@ Subtracks is an Android open source music streaming app for [Subsonic-API-compat
- More shuffle play modes
# Building
+
See [Building from source](BUILDING.md).
# Translations
+
Want to see Subtracks in your language? Visit the project on [Weblate](https://hosted.weblate.org/engage/subtracks/) to help!
diff --git a/app/App.tsx b/app/App.tsx
index 5865b0d..4da23e8 100644
--- a/app/App.tsx
+++ b/app/App.tsx
@@ -1,13 +1,13 @@
+import ProgressHook from '@app/components/ProgressHook'
import RootNavigator from '@app/navigation/RootNavigator'
+import queryClient from '@app/query/queryClient'
import SplashPage from '@app/screens/SplashPage'
+import { useStore } from '@app/state/store'
import colors from '@app/styles/colors'
import React from 'react'
import { StatusBar, StyleSheet, View } from 'react-native'
import { MenuProvider } from 'react-native-popup-menu'
import { QueryClientProvider } from 'react-query'
-import ProgressHook from './components/ProgressHook'
-import queryClient from './queryClient'
-import { useStore } from './state/store'
const Debug = () => {
const currentTrackTitle = useStore(store => store.currentTrack?.title)
diff --git a/app/components/BackgroundHeaderFlatList.tsx b/app/components/GradientBackgroundHeaderFlatList.tsx
similarity index 70%
rename from app/components/BackgroundHeaderFlatList.tsx
rename to app/components/GradientBackgroundHeaderFlatList.tsx
index e419216..f6b1d4e 100644
--- a/app/components/BackgroundHeaderFlatList.tsx
+++ b/app/components/GradientBackgroundHeaderFlatList.tsx
@@ -4,18 +4,18 @@ import colors from '@app/styles/colors'
import GradientBackground, { GradientBackgroundProps } from '@app/components/GradientBackground'
import { useLayout } from '@react-native-community/hooks'
import NothingHere from './NothingHere'
-import ImageGradientBackground, { ImageGradientBackgroundProps } from './ImageGradientBackground'
+import GradientImageBackground, { GradientImageBackgroundProps } from './GradientImageBackground'
-export type BackgroundHeaderFlatListPropsBase = FlatListProps & {
+export type GradientBackgroundHeaderFlatListPropsBase = FlatListProps & {
contentMarginTop?: number
}
-export type BackgroundHeaderFlatListProp = BackgroundHeaderFlatListPropsBase & {
- BackgroundComponent: typeof ImageGradientBackground | typeof GradientBackground
- backgroundProps?: ImageGradientBackgroundProps | GradientBackgroundProps
+export type GradientBackgroundHeaderFlatListProp = GradientBackgroundHeaderFlatListPropsBase & {
+ BackgroundComponent: typeof GradientImageBackground | typeof GradientBackground
+ backgroundProps?: GradientImageBackgroundProps | GradientBackgroundProps
}
-function BackgroundHeaderFlatList(props: BackgroundHeaderFlatListProp) {
+function GradientBackgroundHeaderFlatList(props: GradientBackgroundHeaderFlatListProp) {
const window = useWindowDimensions()
const headerLayout = useLayout()
@@ -51,4 +51,4 @@ const styles = StyleSheet.create({
},
})
-export default BackgroundHeaderFlatList
+export default GradientBackgroundHeaderFlatList
diff --git a/app/components/GradientFlatList.tsx b/app/components/GradientFlatList.tsx
index 953c8cd..ea4f87a 100644
--- a/app/components/GradientFlatList.tsx
+++ b/app/components/GradientFlatList.tsx
@@ -1,13 +1,15 @@
import GradientBackground, { GradientBackgroundProps } from '@app/components/GradientBackground'
import React from 'react'
-import BackgroundHeaderFlatList, { BackgroundHeaderFlatListPropsBase } from './BackgroundHeaderFlatList'
+import GradientBackgroundHeaderFlatList, {
+ GradientBackgroundHeaderFlatListPropsBase,
+} from './GradientBackgroundHeaderFlatList'
-export type GradientFlatListProps = BackgroundHeaderFlatListPropsBase & {
+export type GradientFlatListProps = GradientBackgroundHeaderFlatListPropsBase & {
backgroundProps?: GradientBackgroundProps
}
function GradientFlatList(props: GradientFlatListProps) {
- return
+ return
}
export default GradientFlatList
diff --git a/app/components/ImageGradientBackground.tsx b/app/components/GradientImageBackground.tsx
similarity index 93%
rename from app/components/ImageGradientBackground.tsx
rename to app/components/GradientImageBackground.tsx
index 07d41c5..5410c8c 100644
--- a/app/components/ImageGradientBackground.tsx
+++ b/app/components/GradientImageBackground.tsx
@@ -5,12 +5,12 @@ import { AndroidImageColors } from 'react-native-image-colors/lib/typescript/typ
import colors from '@app/styles/colors'
import GradientBackground, { GradientBackgroundPropsBase } from '@app/components/GradientBackground'
-export type ImageGradientBackgroundProps = GradientBackgroundPropsBase & {
+export type GradientImageBackgroundProps = GradientBackgroundPropsBase & {
imagePath?: string
onGetColor?: (color: string) => void
}
-const ImageGradientBackground: React.FC = ({
+const GradientImageBackground: React.FC = ({
height,
width,
position,
@@ -83,4 +83,4 @@ const ImageGradientBackground: React.FC = ({
)
}
-export default ImageGradientBackground
+export default GradientImageBackground
diff --git a/app/components/GradientImageFlatList.tsx b/app/components/GradientImageFlatList.tsx
new file mode 100644
index 0000000..9ad77a3
--- /dev/null
+++ b/app/components/GradientImageFlatList.tsx
@@ -0,0 +1,15 @@
+import React from 'react'
+import GradientBackgroundHeaderFlatList, {
+ GradientBackgroundHeaderFlatListPropsBase,
+} from './GradientBackgroundHeaderFlatList'
+import GradientImageBackground, { GradientImageBackgroundProps } from './GradientImageBackground'
+
+export type GradientImageFlatListProps = GradientBackgroundHeaderFlatListPropsBase & {
+ backgroundProps?: GradientImageBackgroundProps
+}
+
+function GradientImageFlatList(props: GradientImageFlatListProps) {
+ return
+}
+
+export default GradientImageFlatList
diff --git a/app/components/ImageGradientScrollView.tsx b/app/components/GradientImageScrollView.tsx
similarity index 71%
rename from app/components/ImageGradientScrollView.tsx
rename to app/components/GradientImageScrollView.tsx
index f72d244..85e4250 100644
--- a/app/components/ImageGradientScrollView.tsx
+++ b/app/components/GradientImageScrollView.tsx
@@ -1,11 +1,11 @@
-import ImageGradientBackground, { ImageGradientBackgroundProps } from '@app/components/ImageGradientBackground'
+import GradientImageBackground, { GradientImageBackgroundProps } from '@app/components/GradientImageBackground'
import colors from '@app/styles/colors'
import dimensions from '@app/styles/dimensions'
import React from 'react'
import { ScrollView, ScrollViewProps, useWindowDimensions } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
-const ImageGradientScrollView: React.FC = props => {
+const GradientImageScrollView: React.FC = props => {
const layout = useWindowDimensions()
const paddingTop = useSafeAreaInsets().top
@@ -22,10 +22,10 @@ const ImageGradientScrollView: React.FC
-
+
{props.children}
)
}
-export default ImageGradientScrollView
+export default GradientImageScrollView
diff --git a/app/components/ImageGradientFlatList.tsx b/app/components/ImageGradientFlatList.tsx
deleted file mode 100644
index c10f706..0000000
--- a/app/components/ImageGradientFlatList.tsx
+++ /dev/null
@@ -1,13 +0,0 @@
-import React from 'react'
-import BackgroundHeaderFlatList, { BackgroundHeaderFlatListPropsBase } from './BackgroundHeaderFlatList'
-import ImageGradientBackground, { ImageGradientBackgroundProps } from './ImageGradientBackground'
-
-export type ImageGradientFlatListProps = BackgroundHeaderFlatListPropsBase & {
- backgroundProps?: ImageGradientBackgroundProps
-}
-
-function ImageGradientFlatList(props: ImageGradientFlatListProps) {
- return
-}
-
-export default ImageGradientFlatList
diff --git a/app/hooks/fetch.ts b/app/hooks/fetch.ts
deleted file mode 100644
index a62a777..0000000
--- a/app/hooks/fetch.ts
+++ /dev/null
@@ -1,315 +0,0 @@
-import { CacheItemTypeKey } from '@app/models/cache'
-import { Album, Playlist, Song } from '@app/models/library'
-import { mapAlbum, mapArtist, mapArtistInfo, mapPlaylist, mapSong } from '@app/models/map'
-import queryClient from '@app/queryClient'
-import { useStore } from '@app/state/store'
-import { SubsonicApiClient } from '@app/subsonic/api'
-import { GetAlbumList2TypeBase, Search3Params, StarParams } from '@app/subsonic/params'
-import { cacheDir } from '@app/util/fs'
-import { mapCollectionById } from '@app/util/state'
-import userAgent from '@app/util/userAgent'
-import cd from 'content-disposition'
-import mime from 'mime-types'
-import path from 'path'
-import ReactNativeBlobUtil, { FetchBlobResponse } from 'react-native-blob-util'
-import RNFS from 'react-native-fs'
-import qk from './queryKeys'
-
-export const useClient = () => {
- const client = useStore(store => store.client)
-
- return () => {
- if (!client) {
- throw new Error('no client!')
- }
-
- return client
- }
-}
-
-function cacheStarredData(item: T) {
- queryClient.setQueryData(qk.starredItems(item.id), !!item.starred)
-}
-
-function cacheAlbumCoverArtData(item: T) {
- queryClient.setQueryData(qk.albumCoverArt(item.id), item.coverArt)
-}
-
-export const useFetchArtists = () => {
- const client = useClient()
-
- return async () => {
- const res = await client().getArtists()
-
- res.data.artists.forEach(cacheStarredData)
-
- return mapCollectionById(res.data.artists, mapArtist)
- }
-}
-
-export const useFetchArtist = () => {
- const client = useClient()
-
- return async (id: string) => {
- const res = await client().getArtist({ id })
-
- cacheStarredData(res.data.artist)
- res.data.albums.forEach(cacheStarredData)
-
- res.data.albums.forEach(cacheAlbumCoverArtData)
-
- return {
- artist: mapArtist(res.data.artist),
- albums: res.data.albums.map(mapAlbum),
- }
- }
-}
-
-export const useFetchArtistInfo = () => {
- const client = useClient()
-
- return async (id: string) => {
- const res = await client().getArtistInfo2({ id })
- return mapArtistInfo(id, res.data.artistInfo)
- }
-}
-
-export const useFetchArtistTopSongs = () => {
- const client = useClient()
-
- return async (artistName: string) => {
- const res = await client().getTopSongs({ artist: artistName })
-
- res.data.songs.forEach(cacheStarredData)
-
- return res.data.songs.map(mapSong)
- }
-}
-
-export const useFetchPlaylists = () => {
- const client = useClient()
-
- return async () => {
- const res = await client().getPlaylists()
- return mapCollectionById(res.data.playlists, mapPlaylist)
- }
-}
-
-export const useFetchPlaylist = () => {
- const client = useClient()
-
- return async (id: string): Promise<{ playlist: Playlist; songs?: Song[] }> => {
- const res = await client().getPlaylist({ id })
-
- res.data.playlist.songs.forEach(cacheStarredData)
-
- return {
- playlist: mapPlaylist(res.data.playlist),
- songs: res.data.playlist.songs.map(mapSong),
- }
- }
-}
-
-export async function fetchAlbum(id: string, client: SubsonicApiClient): Promise<{ album: Album; songs?: Song[] }> {
- const res = await client.getAlbum({ id })
-
- cacheStarredData(res.data.album)
- res.data.songs.forEach(cacheStarredData)
-
- cacheAlbumCoverArtData(res.data.album)
-
- return {
- album: mapAlbum(res.data.album),
- songs: res.data.songs.map(mapSong),
- }
-}
-
-export const useFetchAlbum = () => {
- const client = useClient()
- return async (id: string) => fetchAlbum(id, client())
-}
-
-export const useFetchAlbumList = () => {
- const client = useClient()
-
- return async (size: number, offset: number, type: GetAlbumList2TypeBase) => {
- const res = await client().getAlbumList2({ size, offset, type })
-
- res.data.albums.forEach(cacheStarredData)
-
- res.data.albums.forEach(cacheAlbumCoverArtData)
-
- return res.data.albums.map(mapAlbum)
- }
-}
-
-export const useFetchSong = () => {
- const client = useClient()
-
- return async (id: string) => {
- const res = await client().getSong({ id })
-
- cacheStarredData(res.data.song)
-
- return mapSong(res.data.song)
- }
-}
-
-export const useFetchSearchResults = () => {
- const client = useClient()
-
- return async (params: Search3Params) => {
- const res = await client().search3(params)
-
- res.data.artists.forEach(cacheStarredData)
- res.data.albums.forEach(cacheStarredData)
- res.data.songs.forEach(cacheStarredData)
-
- res.data.albums.forEach(cacheAlbumCoverArtData)
-
- return {
- artists: res.data.artists.map(mapArtist),
- albums: res.data.albums.map(mapAlbum),
- songs: res.data.songs.map(mapSong),
- }
- }
-}
-
-export const useFetchStar = () => {
- const client = useClient()
-
- return async (params: StarParams) => {
- await client().star(params)
- return
- }
-}
-
-export const useFetchUnstar = () => {
- const client = useClient()
-
- return async (params: StarParams) => {
- await client().unstar(params)
- return
- }
-}
-
-export type FetchExisingFileOptions = {
- itemType: CacheItemTypeKey
- itemId: string
-}
-
-export async function fetchExistingFile(
- options: FetchExisingFileOptions,
- serverId: string | undefined,
-): Promise {
- const { itemType, itemId } = options
- const fileDir = cacheDir(serverId, itemType, itemId)
-
- try {
- const dir = await RNFS.readDir(fileDir)
- console.log('existing file:', dir[0].path)
- return dir[0].path
- } catch {}
-}
-
-export const useFetchExistingFile = () => {
- const serverId = useStore(store => store.settings.activeServerId)
- return async (options: FetchExisingFileOptions) => fetchExistingFile(options, serverId)
-}
-
-function assertMimeType(expected?: string, actual?: string) {
- expected = expected?.toLowerCase()
- actual = actual?.toLowerCase()
-
- if (!expected || expected === actual) {
- return
- }
-
- if (!expected.includes(';')) {
- actual = actual?.split(';')[0]
- }
-
- if (!expected.includes('/')) {
- actual = actual?.split('/')[0]
- }
-
- if (expected !== actual) {
- throw new Error(`Request does not satisfy expected content type. Expected: ${expected} Actual: ${actual}`)
- }
-}
-
-export type FetchFileOptions = FetchExisingFileOptions & {
- fromUrl: string
- useCacheBuster?: boolean
- expectedContentType?: string
- progress?: (received: number, total: number) => void
-}
-
-export async function fetchFile(options: FetchFileOptions, serverId: string | undefined): Promise {
- let { itemType, itemId, fromUrl, useCacheBuster, expectedContentType, progress } = options
- useCacheBuster = useCacheBuster === undefined ? true : useCacheBuster
-
- const fileDir = cacheDir(serverId, itemType, itemId)
- const filePathNoExt = path.join(fileDir, useCacheBuster ? useStore.getState().settings.cacheBuster : itemType)
-
- try {
- await RNFS.unlink(fileDir)
- } catch {}
-
- const headers = { 'User-Agent': userAgent }
-
- // we send a HEAD first for two reasons:
- // 1. to follow any redirects and get the actual URL (DownloadManager does not support redirects)
- // 2. to obtain the mime-type up front so we can use it for the file extension/validation
- const headRes = await fetch(fromUrl, { method: 'HEAD', headers })
-
- if (headRes.status > 399) {
- throw new Error(`HTTP status error ${headRes.status}. File: ${itemType} ID: ${itemId}`)
- }
-
- const contentType = headRes.headers.get('content-type') || undefined
- assertMimeType(expectedContentType, contentType)
-
- const contentDisposition = headRes.headers.get('content-disposition') || undefined
- const filename = contentDisposition ? cd.parse(contentDisposition).parameters.filename : undefined
-
- let extension: string | undefined
- if (filename) {
- extension = path.extname(filename) || undefined
- if (extension) {
- extension = extension.substring(1)
- }
- } else if (contentType) {
- extension = mime.extension(contentType) || undefined
- }
-
- const config = ReactNativeBlobUtil.config({
- addAndroidDownloads: {
- useDownloadManager: true,
- notification: false,
- mime: contentType,
- description: 'subtracks',
- path: extension ? `${filePathNoExt}.${extension}` : filePathNoExt,
- },
- })
-
- const fetchParams: Parameters = ['GET', headRes.url, headers]
-
- let res: FetchBlobResponse
- if (progress) {
- res = await config.fetch(...fetchParams).progress(progress)
- } else {
- res = await config.fetch(...fetchParams)
- }
-
- const downloadPath = res.path()
- queryClient.setQueryData(qk.existingFiles(itemType, itemId), downloadPath)
-
- console.log('downloaded file:', downloadPath)
- return downloadPath
-}
-
-export const useFetchFile = () => {
- const serverId = useStore(store => store.settings.activeServerId)
- return async (options: FetchFileOptions) => fetchFile(options, serverId)
-}
diff --git a/app/hooks/query.ts b/app/hooks/query.ts
index 9942ba9..c24699a 100644
--- a/app/hooks/query.ts
+++ b/app/hooks/query.ts
@@ -1,7 +1,7 @@
import { CacheImageSize, CacheItemTypeKey } from '@app/models/cache'
import { Album, Artist, Playlist, Song, StarrableItemType } from '@app/models/library'
import { CollectionById } from '@app/models/state'
-import queryClient from '@app/queryClient'
+import queryClient from '@app/query/queryClient'
import { useStore } from '@app/state/store'
import { GetAlbumList2TypeBase, Search3Params, StarParams } from '@app/subsonic/params'
import _ from 'lodash'
@@ -13,16 +13,15 @@ import {
useFetchArtistInfo,
useFetchArtists,
useFetchArtistTopSongs,
- useFetchExistingFile,
- useFetchFile,
useFetchPlaylist,
useFetchPlaylists,
useFetchSearchResults,
useFetchSong,
useFetchStar,
useFetchUnstar,
-} from './fetch'
-import qk from './queryKeys'
+} from '../query/fetch/api'
+import qk from '@app/query/queryKeys'
+import { useFetchExistingFile, useFetchFile } from '@app/query/fetch/file'
export const useQueryArtists = () => useQuery(qk.artists, useFetchArtists())
diff --git a/app/hooks/settings.ts b/app/hooks/settings.ts
index d0a714d..e159ddb 100644
--- a/app/hooks/settings.ts
+++ b/app/hooks/settings.ts
@@ -1,10 +1,10 @@
import { useReset } from '@app/hooks/trackplayer'
import { CacheItemTypeKey } from '@app/models/cache'
-import queryClient from '@app/queryClient'
+import queryClient from '@app/query/queryClient'
import { useStore, useStoreDeep } from '@app/state/store'
-import { cacheDir } from '@app/util/fs'
+import cacheDir from '@app/util/cacheDir'
import RNFS from 'react-native-fs'
-import qk from './queryKeys'
+import qk from '@app/query/queryKeys'
export const useSwitchActiveServer = () => {
const activeServerId = useStore(store => store.settings.activeServerId)
diff --git a/app/hooks/trackplayer.ts b/app/hooks/trackplayer.ts
index 4bd6f8d..50a5f2a 100644
--- a/app/hooks/trackplayer.ts
+++ b/app/hooks/trackplayer.ts
@@ -1,12 +1,12 @@
import { Song } from '@app/models/library'
import { QueueContextType, TrackExt } from '@app/models/trackplayer'
-import queryClient from '@app/queryClient'
-import queueService from '@app/queueservice'
+import queryClient from '@app/query/queryClient'
+import QueueEvents from '@app/trackplayer/QueueEvents'
import { useStore, useStoreDeep } from '@app/state/store'
import { getQueue, SetQueueOptions, trackPlayerCommands } from '@app/state/trackplayer'
import userAgent from '@app/util/userAgent'
import TrackPlayer from 'react-native-track-player'
-import qk from './queryKeys'
+import qk from '@app/query/queryKeys'
export const usePlay = () => {
return () => trackPlayerCommands.enqueue(() => TrackPlayer.play())
@@ -132,7 +132,7 @@ export const useSetQueue = (type: QueueContextType, songs?: Song[]) => {
}
await _setQueue({ queue, type, contextId, ...options })
- queueService.emit('set', { queue })
+ QueueEvents.emit('set', { queue })
}
return { setQueue, contextId }
diff --git a/app/hooks/useClient.ts b/app/hooks/useClient.ts
new file mode 100644
index 0000000..ec68f21
--- /dev/null
+++ b/app/hooks/useClient.ts
@@ -0,0 +1,15 @@
+import { useStore } from '@app/state/store'
+
+const useClient = () => {
+ const client = useStore(store => store.client)
+
+ return () => {
+ if (!client) {
+ throw new Error('no client!')
+ }
+
+ return client
+ }
+}
+
+export default useClient
diff --git a/app/i18n.ts b/app/i18n/i18n.ts
similarity index 100%
rename from app/i18n.ts
rename to app/i18n/i18n.ts
diff --git a/app/query/fetch/api.ts b/app/query/fetch/api.ts
new file mode 100644
index 0000000..265a950
--- /dev/null
+++ b/app/query/fetch/api.ts
@@ -0,0 +1,174 @@
+import useClient from '@app/hooks/useClient'
+import { Album, Playlist, Song } from '@app/models/library'
+import { mapAlbum, mapArtist, mapArtistInfo, mapPlaylist, mapSong } from '@app/models/map'
+import queryClient from '@app/query/queryClient'
+import qk from '@app/query/queryKeys'
+import { SubsonicApiClient } from '@app/subsonic/api'
+import { GetAlbumList2TypeBase, Search3Params, StarParams } from '@app/subsonic/params'
+import { mapCollectionById } from '@app/util/state'
+
+function cacheStarredData(item: T) {
+ queryClient.setQueryData(qk.starredItems(item.id), !!item.starred)
+}
+
+function cacheAlbumCoverArtData(item: T) {
+ queryClient.setQueryData(qk.albumCoverArt(item.id), item.coverArt)
+}
+
+export const useFetchArtists = () => {
+ const client = useClient()
+
+ return async () => {
+ const res = await client().getArtists()
+
+ res.data.artists.forEach(cacheStarredData)
+
+ return mapCollectionById(res.data.artists, mapArtist)
+ }
+}
+
+export const useFetchArtist = () => {
+ const client = useClient()
+
+ return async (id: string) => {
+ const res = await client().getArtist({ id })
+
+ cacheStarredData(res.data.artist)
+ res.data.albums.forEach(cacheStarredData)
+
+ res.data.albums.forEach(cacheAlbumCoverArtData)
+
+ return {
+ artist: mapArtist(res.data.artist),
+ albums: res.data.albums.map(mapAlbum),
+ }
+ }
+}
+
+export const useFetchArtistInfo = () => {
+ const client = useClient()
+
+ return async (id: string) => {
+ const res = await client().getArtistInfo2({ id })
+ return mapArtistInfo(id, res.data.artistInfo)
+ }
+}
+
+export const useFetchArtistTopSongs = () => {
+ const client = useClient()
+
+ return async (artistName: string) => {
+ const res = await client().getTopSongs({ artist: artistName })
+
+ res.data.songs.forEach(cacheStarredData)
+
+ return res.data.songs.map(mapSong)
+ }
+}
+
+export const useFetchPlaylists = () => {
+ const client = useClient()
+
+ return async () => {
+ const res = await client().getPlaylists()
+ return mapCollectionById(res.data.playlists, mapPlaylist)
+ }
+}
+
+export const useFetchPlaylist = () => {
+ const client = useClient()
+
+ return async (id: string): Promise<{ playlist: Playlist; songs?: Song[] }> => {
+ const res = await client().getPlaylist({ id })
+
+ res.data.playlist.songs.forEach(cacheStarredData)
+
+ return {
+ playlist: mapPlaylist(res.data.playlist),
+ songs: res.data.playlist.songs.map(mapSong),
+ }
+ }
+}
+
+export async function fetchAlbum(id: string, client: SubsonicApiClient): Promise<{ album: Album; songs?: Song[] }> {
+ const res = await client.getAlbum({ id })
+
+ cacheStarredData(res.data.album)
+ res.data.songs.forEach(cacheStarredData)
+
+ cacheAlbumCoverArtData(res.data.album)
+
+ return {
+ album: mapAlbum(res.data.album),
+ songs: res.data.songs.map(mapSong),
+ }
+}
+
+export const useFetchAlbum = () => {
+ const client = useClient()
+ return async (id: string) => fetchAlbum(id, client())
+}
+
+export const useFetchAlbumList = () => {
+ const client = useClient()
+
+ return async (size: number, offset: number, type: GetAlbumList2TypeBase) => {
+ const res = await client().getAlbumList2({ size, offset, type })
+
+ res.data.albums.forEach(cacheStarredData)
+
+ res.data.albums.forEach(cacheAlbumCoverArtData)
+
+ return res.data.albums.map(mapAlbum)
+ }
+}
+
+export const useFetchSong = () => {
+ const client = useClient()
+
+ return async (id: string) => {
+ const res = await client().getSong({ id })
+
+ cacheStarredData(res.data.song)
+
+ return mapSong(res.data.song)
+ }
+}
+
+export const useFetchSearchResults = () => {
+ const client = useClient()
+
+ return async (params: Search3Params) => {
+ const res = await client().search3(params)
+
+ res.data.artists.forEach(cacheStarredData)
+ res.data.albums.forEach(cacheStarredData)
+ res.data.songs.forEach(cacheStarredData)
+
+ res.data.albums.forEach(cacheAlbumCoverArtData)
+
+ return {
+ artists: res.data.artists.map(mapArtist),
+ albums: res.data.albums.map(mapAlbum),
+ songs: res.data.songs.map(mapSong),
+ }
+ }
+}
+
+export const useFetchStar = () => {
+ const client = useClient()
+
+ return async (params: StarParams) => {
+ await client().star(params)
+ return
+ }
+}
+
+export const useFetchUnstar = () => {
+ const client = useClient()
+
+ return async (params: StarParams) => {
+ await client().unstar(params)
+ return
+ }
+}
diff --git a/app/query/fetch/file.ts b/app/query/fetch/file.ts
new file mode 100644
index 0000000..7dc7741
--- /dev/null
+++ b/app/query/fetch/file.ts
@@ -0,0 +1,132 @@
+import { CacheItemTypeKey } from '@app/models/cache'
+import queryClient from '@app/query/queryClient'
+import qk from '@app/query/queryKeys'
+import { useStore } from '@app/state/store'
+import cacheDir from '@app/util/cacheDir'
+import userAgent from '@app/util/userAgent'
+import cd from 'content-disposition'
+import mime from 'mime-types'
+import path from 'path'
+import ReactNativeBlobUtil, { FetchBlobResponse } from 'react-native-blob-util'
+import RNFS from 'react-native-fs'
+
+export type FetchExisingFileOptions = {
+ itemType: CacheItemTypeKey
+ itemId: string
+}
+
+export async function fetchExistingFile(
+ options: FetchExisingFileOptions,
+ serverId: string | undefined,
+): Promise {
+ const { itemType, itemId } = options
+ const fileDir = cacheDir(serverId, itemType, itemId)
+
+ try {
+ const dir = await RNFS.readDir(fileDir)
+ console.log('existing file:', dir[0].path)
+ return dir[0].path
+ } catch {}
+}
+
+export const useFetchExistingFile = () => {
+ const serverId = useStore(store => store.settings.activeServerId)
+ return async (options: FetchExisingFileOptions) => fetchExistingFile(options, serverId)
+}
+
+function assertMimeType(expected?: string, actual?: string) {
+ expected = expected?.toLowerCase()
+ actual = actual?.toLowerCase()
+
+ if (!expected || expected === actual) {
+ return
+ }
+
+ if (!expected.includes(';')) {
+ actual = actual?.split(';')[0]
+ }
+
+ if (!expected.includes('/')) {
+ actual = actual?.split('/')[0]
+ }
+
+ if (expected !== actual) {
+ throw new Error(`Request does not satisfy expected content type. Expected: ${expected} Actual: ${actual}`)
+ }
+}
+
+export type FetchFileOptions = FetchExisingFileOptions & {
+ fromUrl: string
+ useCacheBuster?: boolean
+ expectedContentType?: string
+ progress?: (received: number, total: number) => void
+}
+
+export async function fetchFile(options: FetchFileOptions, serverId: string | undefined): Promise {
+ let { itemType, itemId, fromUrl, useCacheBuster, expectedContentType, progress } = options
+ useCacheBuster = useCacheBuster === undefined ? true : useCacheBuster
+
+ const fileDir = cacheDir(serverId, itemType, itemId)
+ const filePathNoExt = path.join(fileDir, useCacheBuster ? useStore.getState().settings.cacheBuster : itemType)
+
+ try {
+ await RNFS.unlink(fileDir)
+ } catch {}
+
+ const headers = { 'User-Agent': userAgent }
+
+ // we send a HEAD first for two reasons:
+ // 1. to follow any redirects and get the actual URL (DownloadManager does not support redirects)
+ // 2. to obtain the mime-type up front so we can use it for the file extension/validation
+ const headRes = await fetch(fromUrl, { method: 'HEAD', headers })
+
+ if (headRes.status > 399) {
+ throw new Error(`HTTP status error ${headRes.status}. File: ${itemType} ID: ${itemId}`)
+ }
+
+ const contentType = headRes.headers.get('content-type') || undefined
+ assertMimeType(expectedContentType, contentType)
+
+ const contentDisposition = headRes.headers.get('content-disposition') || undefined
+ const filename = contentDisposition ? cd.parse(contentDisposition).parameters.filename : undefined
+
+ let extension: string | undefined
+ if (filename) {
+ extension = path.extname(filename) || undefined
+ if (extension) {
+ extension = extension.substring(1)
+ }
+ } else if (contentType) {
+ extension = mime.extension(contentType) || undefined
+ }
+
+ const config = ReactNativeBlobUtil.config({
+ addAndroidDownloads: {
+ useDownloadManager: true,
+ notification: false,
+ mime: contentType,
+ description: 'subtracks',
+ path: extension ? `${filePathNoExt}.${extension}` : filePathNoExt,
+ },
+ })
+
+ const fetchParams: Parameters = ['GET', headRes.url, headers]
+
+ let res: FetchBlobResponse
+ if (progress) {
+ res = await config.fetch(...fetchParams).progress(progress)
+ } else {
+ res = await config.fetch(...fetchParams)
+ }
+
+ const downloadPath = res.path()
+ queryClient.setQueryData(qk.existingFiles(itemType, itemId), downloadPath)
+
+ console.log('downloaded file:', downloadPath)
+ return downloadPath
+}
+
+export const useFetchFile = () => {
+ const serverId = useStore(store => store.settings.activeServerId)
+ return async (options: FetchFileOptions) => fetchFile(options, serverId)
+}
diff --git a/app/queryClient.ts b/app/query/queryClient.ts
similarity index 100%
rename from app/queryClient.ts
rename to app/query/queryClient.ts
diff --git a/app/hooks/queryKeys.ts b/app/query/queryKeys.ts
similarity index 100%
rename from app/hooks/queryKeys.ts
rename to app/query/queryKeys.ts
diff --git a/app/screens/NowPlayingView.tsx b/app/screens/NowPlayingView.tsx
index 73df61a..549fcc3 100644
--- a/app/screens/NowPlayingView.tsx
+++ b/app/screens/NowPlayingView.tsx
@@ -1,6 +1,6 @@
import CoverArt from '@app/components/CoverArt'
import HeaderBar from '@app/components/HeaderBar'
-import ImageGradientBackground from '@app/components/ImageGradientBackground'
+import GradientImageBackground from '@app/components/GradientImageBackground'
import PressableOpacity from '@app/components/PressableOpacity'
import { PressableStar } from '@app/components/Star'
import { withSuspenseMemo } from '@app/components/withSuspense'
@@ -397,7 +397,7 @@ const NowPlayingView: React.FC = ({ navigation }) => {
return (
-
+
diff --git a/app/screens/SongListView.tsx b/app/screens/SongListView.tsx
index 2886902..a25ea13 100644
--- a/app/screens/SongListView.tsx
+++ b/app/screens/SongListView.tsx
@@ -1,7 +1,7 @@
import CoverArt from '@app/components/CoverArt'
import GradientBackground from '@app/components/GradientBackground'
import HeaderBar from '@app/components/HeaderBar'
-import ImageGradientFlatList from '@app/components/ImageGradientFlatList'
+import GradientImageFlatList from '@app/components/GradientImageFlatList'
import ListItem from '@app/components/ListItem'
import ListPlayerControls from '@app/components/ListPlayerControls'
import NothingHere from '@app/components/NothingHere'
@@ -86,7 +86,7 @@ const SongListDetails = React.memo<{
title={title}
contextItem={songList.itemType === 'album' ? songList : undefined}
/>
- ({
song: s,
contextId,
diff --git a/app/queueservice.ts b/app/trackplayer/QueueEvents.ts
similarity index 75%
rename from app/queueservice.ts
rename to app/trackplayer/QueueEvents.ts
index 2b5411d..89c0a01 100644
--- a/app/queueservice.ts
+++ b/app/trackplayer/QueueEvents.ts
@@ -1,8 +1,8 @@
/* eslint-disable no-dupe-class-members */
import { EmitterSubscription, NativeEventEmitter } from 'react-native'
-import { TrackExt } from './models/trackplayer'
+import { TrackExt } from '@app/models/trackplayer'
-class QueueService extends NativeEventEmitter {
+class QueueEventEmitter extends NativeEventEmitter {
addListener(eventType: 'set', listener: (event: { queue: TrackExt[] }) => void): EmitterSubscription
addListener(eventType: string, listener: (event: any) => void, context?: Object): EmitterSubscription {
return super.addListener(eventType, listener, context)
@@ -14,5 +14,5 @@ class QueueService extends NativeEventEmitter {
}
}
-const queueService = new QueueService()
-export default queueService
+const QueueEvents = new QueueEventEmitter()
+export default QueueEvents
diff --git a/app/playbackservice.ts b/app/trackplayer/service.ts
similarity index 94%
rename from app/playbackservice.ts
rename to app/trackplayer/service.ts
index 27daa57..e3cd71e 100644
--- a/app/playbackservice.ts
+++ b/app/trackplayer/service.ts
@@ -1,14 +1,15 @@
+import { fetchAlbum } from '@app/query/fetch/api'
+import { FetchExisingFileOptions, fetchExistingFile, fetchFile, FetchFileOptions } from '@app/query/fetch/file'
+import qk from '@app/query/queryKeys'
import { getCurrentTrack, getPlayerState, trackPlayerCommands } from '@app/state/trackplayer'
import NetInfo, { NetInfoStateType } from '@react-native-community/netinfo'
import _ from 'lodash'
import { unstable_batchedUpdates } from 'react-native'
import TrackPlayer, { Event, State } from 'react-native-track-player'
-import { fetchAlbum, FetchExisingFileOptions, fetchExistingFile, fetchFile, FetchFileOptions } from './hooks/fetch'
-import qk from './hooks/queryKeys'
-import queryClient from './queryClient'
-import queueService from './queueservice'
-import { useStore } from './state/store'
-import { ReturnedPromiseResolvedType } from './util/types'
+import queryClient from '../query/queryClient'
+import { useStore } from '../state/store'
+import { ReturnedPromiseResolvedType } from '../util/types'
+import QueueEvents from './QueueEvents'
const reset = () => {
unstable_batchedUpdates(() => {
@@ -218,7 +219,7 @@ const createService = async () => {
}
})
- queueService.addListener('set', async ({ queue }) => {
+ QueueEvents.addListener('set', async ({ queue }) => {
const contextId = useStore.getState().queueContextId
const throwIfQueueChanged = () => {
if (contextId !== useStore.getState().queueContextId) {
diff --git a/app/util/fs.ts b/app/util/cacheDir.ts
similarity index 77%
rename from app/util/fs.ts
rename to app/util/cacheDir.ts
index e783b75..46310b2 100644
--- a/app/util/fs.ts
+++ b/app/util/cacheDir.ts
@@ -4,7 +4,7 @@ import { CacheItemTypeKey } from '@app/models/cache'
const serversCacheDir = path.join(RNFS.ExternalDirectoryPath, 's')
-export function cacheDir(serverId?: string, itemType?: CacheItemTypeKey, itemId?: string): string {
+function cacheDir(serverId?: string, itemType?: CacheItemTypeKey, itemId?: string): string {
const segments: string[] = []
serverId && segments.push(serverId)
@@ -13,3 +13,5 @@ export function cacheDir(serverId?: string, itemType?: CacheItemTypeKey, itemId?
return path.join(serversCacheDir, ...segments)
}
+
+export default cacheDir
diff --git a/index.js b/index.js
index 75ec3eb..2d344c5 100644
--- a/index.js
+++ b/index.js
@@ -13,7 +13,7 @@ LogBox.ignoreLogs([
import i18next from 'i18next'
import { initReactI18next } from 'react-i18next'
-import { backend, languageDetector } from '@app/i18n'
+import { backend, languageDetector } from '@app/i18n/i18n'
import * as RNLocalize from 'react-native-localize'
i18next.use(backend).use(languageDetector).use(initReactI18next).init({
@@ -32,7 +32,7 @@ import { name as appName } from '@app/app.json'
import TrackPlayer, { Capability } from 'react-native-track-player'
AppRegistry.registerComponent(appName, () => App)
-TrackPlayer.registerPlaybackService(() => require('@app/playbackservice'))
+TrackPlayer.registerPlaybackService(() => require('@app/trackplayer/service'))
async function start() {
await TrackPlayer.setupPlayer()