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 logo +subtracks logo -# -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. [![Translation status](https://hosted.weblate.org/widgets/subtracks/-/subtracks/svg-badge.svg)](https://hosted.weblate.org/engage/subtracks/) ![GitHub Workflow Status (branch)](https://img.shields.io/github/workflow/status/austinried/subtracks/build-release-debugsign/main) ![GitHub release (latest SemVer)](https://img.shields.io/github/v/release/austinried/subtracks?label=github) ![F-Droid](https://img.shields.io/f-droid/v/com.subtracks) # Screenshots +

home now playing @@ -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()