minor reorg/cleanup

This commit is contained in:
austinried 2022-04-28 15:41:45 +09:00
parent 2bf3e8853d
commit 27754bd3c3
29 changed files with 403 additions and 386 deletions

View File

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

View File

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

View File

@ -1,2 +0,0 @@
BUNDLE_PATH: "vendor/bundle"
BUNDLE_FORCE_RUBY_PLATFORM: 1

View File

@ -1,11 +1,13 @@
<img src="assets/header.png" alt="subtracks logo" width="500"/> <img src=".assets/header.png" alt="subtracks logo" width="500"/>
# #
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) [![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 # Screenshots
<p float="left"> <p float="left">
<img src="metadata/en-US/images/phoneScreenshots/01_home.png" alt="home" width="200"/> <img src="metadata/en-US/images/phoneScreenshots/01_home.png" alt="home" width="200"/>
<img src="metadata/en-US/images/phoneScreenshots/02_now-playing.png" alt="now playing" width="200"/> <img src="metadata/en-US/images/phoneScreenshots/02_now-playing.png" alt="now playing" width="200"/>
@ -14,15 +16,17 @@ Subtracks is an Android open source music streaming app for [Subsonic-API-compat
</p> </p>
# Download # Download
<p float="left"> <p float="left">
<a href="https://play.google.com/store/apps/details?id=com.subtracks"><img src="assets/google-play-badge.png" width="250"/></a> <a href="https://play.google.com/store/apps/details?id=com.subtracks"><img src=".assets/google-play-badge.png" width="250"/></a>
<a href="https://f-droid.org/en/packages/com.subtracks/"><img src="assets/f-droid-badge.png" width="250"></a> <a href="https://f-droid.org/en/packages/com.subtracks/"><img src=".assets/f-droid-badge.png" width="250"></a>
<a href="https://github.com/austinried/subtracks/releases/latest"><img src="assets/github-badge.png" width="250"/></a> <a href="https://github.com/austinried/subtracks/releases/latest"><img src=".assets/github-badge.png" width="250"/></a>
</p> </p>
> :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. > :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 # Features
- Album and artist art display by default (full-res in detail/now playing views) - Album and artist art display by default (full-res in detail/now playing views)
- Gapless playback - Gapless playback
- Mulitple server support - 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 - Long-press for context menu shortcuts
# Coming Soon™ # Coming Soon™
- Offline support - Offline support
- Customizable home screen categories - Customizable home screen categories
- Browse by folder support (currently only browses by tags) - 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 - More shuffle play modes
# Building # Building
See [Building from source](BUILDING.md). See [Building from source](BUILDING.md).
# Translations # Translations
Want to see Subtracks in your language? Visit the project on [Weblate](https://hosted.weblate.org/engage/subtracks/) to help! Want to see Subtracks in your language? Visit the project on [Weblate](https://hosted.weblate.org/engage/subtracks/) to help!
<a href="https://hosted.weblate.org/engage/subtracks/"> <a href="https://hosted.weblate.org/engage/subtracks/">

View File

@ -1,13 +1,13 @@
import ProgressHook from '@app/components/ProgressHook'
import RootNavigator from '@app/navigation/RootNavigator' import RootNavigator from '@app/navigation/RootNavigator'
import queryClient from '@app/query/queryClient'
import SplashPage from '@app/screens/SplashPage' import SplashPage from '@app/screens/SplashPage'
import { useStore } from '@app/state/store'
import colors from '@app/styles/colors' import colors from '@app/styles/colors'
import React from 'react' import React from 'react'
import { StatusBar, StyleSheet, View } from 'react-native' import { StatusBar, StyleSheet, View } from 'react-native'
import { MenuProvider } from 'react-native-popup-menu' import { MenuProvider } from 'react-native-popup-menu'
import { QueryClientProvider } from 'react-query' import { QueryClientProvider } from 'react-query'
import ProgressHook from './components/ProgressHook'
import queryClient from './queryClient'
import { useStore } from './state/store'
const Debug = () => { const Debug = () => {
const currentTrackTitle = useStore(store => store.currentTrack?.title) const currentTrackTitle = useStore(store => store.currentTrack?.title)

View File

@ -4,18 +4,18 @@ import colors from '@app/styles/colors'
import GradientBackground, { GradientBackgroundProps } from '@app/components/GradientBackground' import GradientBackground, { GradientBackgroundProps } from '@app/components/GradientBackground'
import { useLayout } from '@react-native-community/hooks' import { useLayout } from '@react-native-community/hooks'
import NothingHere from './NothingHere' import NothingHere from './NothingHere'
import ImageGradientBackground, { ImageGradientBackgroundProps } from './ImageGradientBackground' import GradientImageBackground, { GradientImageBackgroundProps } from './GradientImageBackground'
export type BackgroundHeaderFlatListPropsBase<ItemT> = FlatListProps<ItemT> & { export type GradientBackgroundHeaderFlatListPropsBase<ItemT> = FlatListProps<ItemT> & {
contentMarginTop?: number contentMarginTop?: number
} }
export type BackgroundHeaderFlatListProp<ItemT> = BackgroundHeaderFlatListPropsBase<ItemT> & { export type GradientBackgroundHeaderFlatListProp<ItemT> = GradientBackgroundHeaderFlatListPropsBase<ItemT> & {
BackgroundComponent: typeof ImageGradientBackground | typeof GradientBackground BackgroundComponent: typeof GradientImageBackground | typeof GradientBackground
backgroundProps?: ImageGradientBackgroundProps | GradientBackgroundProps backgroundProps?: GradientImageBackgroundProps | GradientBackgroundProps
} }
function BackgroundHeaderFlatList<ItemT>(props: BackgroundHeaderFlatListProp<ItemT>) { function GradientBackgroundHeaderFlatList<ItemT>(props: GradientBackgroundHeaderFlatListProp<ItemT>) {
const window = useWindowDimensions() const window = useWindowDimensions()
const headerLayout = useLayout() const headerLayout = useLayout()
@ -51,4 +51,4 @@ const styles = StyleSheet.create({
}, },
}) })
export default BackgroundHeaderFlatList export default GradientBackgroundHeaderFlatList

View File

@ -1,13 +1,15 @@
import GradientBackground, { GradientBackgroundProps } from '@app/components/GradientBackground' import GradientBackground, { GradientBackgroundProps } from '@app/components/GradientBackground'
import React from 'react' import React from 'react'
import BackgroundHeaderFlatList, { BackgroundHeaderFlatListPropsBase } from './BackgroundHeaderFlatList' import GradientBackgroundHeaderFlatList, {
GradientBackgroundHeaderFlatListPropsBase,
} from './GradientBackgroundHeaderFlatList'
export type GradientFlatListProps<ItemT> = BackgroundHeaderFlatListPropsBase<ItemT> & { export type GradientFlatListProps<ItemT> = GradientBackgroundHeaderFlatListPropsBase<ItemT> & {
backgroundProps?: GradientBackgroundProps backgroundProps?: GradientBackgroundProps
} }
function GradientFlatList<ItemT>(props: GradientFlatListProps<ItemT>) { function GradientFlatList<ItemT>(props: GradientFlatListProps<ItemT>) {
return <BackgroundHeaderFlatList BackgroundComponent={GradientBackground} {...props} /> return <GradientBackgroundHeaderFlatList BackgroundComponent={GradientBackground} {...props} />
} }
export default GradientFlatList export default GradientFlatList

View File

@ -5,12 +5,12 @@ import { AndroidImageColors } from 'react-native-image-colors/lib/typescript/typ
import colors from '@app/styles/colors' import colors from '@app/styles/colors'
import GradientBackground, { GradientBackgroundPropsBase } from '@app/components/GradientBackground' import GradientBackground, { GradientBackgroundPropsBase } from '@app/components/GradientBackground'
export type ImageGradientBackgroundProps = GradientBackgroundPropsBase & { export type GradientImageBackgroundProps = GradientBackgroundPropsBase & {
imagePath?: string imagePath?: string
onGetColor?: (color: string) => void onGetColor?: (color: string) => void
} }
const ImageGradientBackground: React.FC<ImageGradientBackgroundProps> = ({ const GradientImageBackground: React.FC<GradientImageBackgroundProps> = ({
height, height,
width, width,
position, position,
@ -83,4 +83,4 @@ const ImageGradientBackground: React.FC<ImageGradientBackgroundProps> = ({
) )
} }
export default ImageGradientBackground export default GradientImageBackground

View File

@ -0,0 +1,15 @@
import React from 'react'
import GradientBackgroundHeaderFlatList, {
GradientBackgroundHeaderFlatListPropsBase,
} from './GradientBackgroundHeaderFlatList'
import GradientImageBackground, { GradientImageBackgroundProps } from './GradientImageBackground'
export type GradientImageFlatListProps<ItemT> = GradientBackgroundHeaderFlatListPropsBase<ItemT> & {
backgroundProps?: GradientImageBackgroundProps
}
function GradientImageFlatList<ItemT>(props: GradientImageFlatListProps<ItemT>) {
return <GradientBackgroundHeaderFlatList BackgroundComponent={GradientImageBackground} {...props} />
}
export default GradientImageFlatList

View File

@ -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 colors from '@app/styles/colors'
import dimensions from '@app/styles/dimensions' import dimensions from '@app/styles/dimensions'
import React from 'react' import React from 'react'
import { ScrollView, ScrollViewProps, useWindowDimensions } from 'react-native' import { ScrollView, ScrollViewProps, useWindowDimensions } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context' import { useSafeAreaInsets } from 'react-native-safe-area-context'
const ImageGradientScrollView: React.FC<ScrollViewProps & ImageGradientBackgroundProps> = props => { const GradientImageScrollView: React.FC<ScrollViewProps & GradientImageBackgroundProps> = props => {
const layout = useWindowDimensions() const layout = useWindowDimensions()
const paddingTop = useSafeAreaInsets().top const paddingTop = useSafeAreaInsets().top
@ -22,10 +22,10 @@ const ImageGradientScrollView: React.FC<ScrollViewProps & ImageGradientBackgroun
}, },
]} ]}
contentContainerStyle={[{ minHeight }, props.contentContainerStyle]}> contentContainerStyle={[{ minHeight }, props.contentContainerStyle]}>
<ImageGradientBackground height={minHeight} imagePath={props.imagePath} onGetColor={props.onGetColor} /> <GradientImageBackground height={minHeight} imagePath={props.imagePath} onGetColor={props.onGetColor} />
{props.children} {props.children}
</ScrollView> </ScrollView>
) )
} }
export default ImageGradientScrollView export default GradientImageScrollView

View File

@ -1,13 +0,0 @@
import React from 'react'
import BackgroundHeaderFlatList, { BackgroundHeaderFlatListPropsBase } from './BackgroundHeaderFlatList'
import ImageGradientBackground, { ImageGradientBackgroundProps } from './ImageGradientBackground'
export type ImageGradientFlatListProps<ItemT> = BackgroundHeaderFlatListPropsBase<ItemT> & {
backgroundProps?: ImageGradientBackgroundProps
}
function ImageGradientFlatList<ItemT>(props: ImageGradientFlatListProps<ItemT>) {
return <BackgroundHeaderFlatList BackgroundComponent={ImageGradientBackground} {...props} />
}
export default ImageGradientFlatList

View File

@ -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<T extends { id: string; starred?: undefined | any }>(item: T) {
queryClient.setQueryData<boolean>(qk.starredItems(item.id), !!item.starred)
}
function cacheAlbumCoverArtData<T extends { id: string; coverArt?: string }>(item: T) {
queryClient.setQueryData<string | undefined>(qk.albumCoverArt(item.id), item.coverArt)
}
export const useFetchArtists = () => {
const client = useClient()
return async () => {
const res = await client().getArtists()
res.data.artists.forEach(cacheStarredData)
return mapCollectionById(res.data.artists, mapArtist)
}
}
export const useFetchArtist = () => {
const client = useClient()
return async (id: string) => {
const res = await client().getArtist({ id })
cacheStarredData(res.data.artist)
res.data.albums.forEach(cacheStarredData)
res.data.albums.forEach(cacheAlbumCoverArtData)
return {
artist: mapArtist(res.data.artist),
albums: res.data.albums.map(mapAlbum),
}
}
}
export const useFetchArtistInfo = () => {
const client = useClient()
return async (id: string) => {
const res = await client().getArtistInfo2({ id })
return mapArtistInfo(id, res.data.artistInfo)
}
}
export const useFetchArtistTopSongs = () => {
const client = useClient()
return async (artistName: string) => {
const res = await client().getTopSongs({ artist: artistName })
res.data.songs.forEach(cacheStarredData)
return res.data.songs.map(mapSong)
}
}
export const useFetchPlaylists = () => {
const client = useClient()
return async () => {
const res = await client().getPlaylists()
return mapCollectionById(res.data.playlists, mapPlaylist)
}
}
export const useFetchPlaylist = () => {
const client = useClient()
return async (id: string): Promise<{ playlist: Playlist; songs?: Song[] }> => {
const res = await client().getPlaylist({ id })
res.data.playlist.songs.forEach(cacheStarredData)
return {
playlist: mapPlaylist(res.data.playlist),
songs: res.data.playlist.songs.map(mapSong),
}
}
}
export async function fetchAlbum(id: string, client: SubsonicApiClient): Promise<{ album: Album; songs?: Song[] }> {
const res = await client.getAlbum({ id })
cacheStarredData(res.data.album)
res.data.songs.forEach(cacheStarredData)
cacheAlbumCoverArtData(res.data.album)
return {
album: mapAlbum(res.data.album),
songs: res.data.songs.map(mapSong),
}
}
export const useFetchAlbum = () => {
const client = useClient()
return async (id: string) => fetchAlbum(id, client())
}
export const useFetchAlbumList = () => {
const client = useClient()
return async (size: number, offset: number, type: GetAlbumList2TypeBase) => {
const res = await client().getAlbumList2({ size, offset, type })
res.data.albums.forEach(cacheStarredData)
res.data.albums.forEach(cacheAlbumCoverArtData)
return res.data.albums.map(mapAlbum)
}
}
export const useFetchSong = () => {
const client = useClient()
return async (id: string) => {
const res = await client().getSong({ id })
cacheStarredData(res.data.song)
return mapSong(res.data.song)
}
}
export const useFetchSearchResults = () => {
const client = useClient()
return async (params: Search3Params) => {
const res = await client().search3(params)
res.data.artists.forEach(cacheStarredData)
res.data.albums.forEach(cacheStarredData)
res.data.songs.forEach(cacheStarredData)
res.data.albums.forEach(cacheAlbumCoverArtData)
return {
artists: res.data.artists.map(mapArtist),
albums: res.data.albums.map(mapAlbum),
songs: res.data.songs.map(mapSong),
}
}
}
export const useFetchStar = () => {
const client = useClient()
return async (params: StarParams) => {
await client().star(params)
return
}
}
export const useFetchUnstar = () => {
const client = useClient()
return async (params: StarParams) => {
await client().unstar(params)
return
}
}
export type FetchExisingFileOptions = {
itemType: CacheItemTypeKey
itemId: string
}
export async function fetchExistingFile(
options: FetchExisingFileOptions,
serverId: string | undefined,
): Promise<string | undefined> {
const { itemType, itemId } = options
const fileDir = cacheDir(serverId, itemType, itemId)
try {
const dir = await RNFS.readDir(fileDir)
console.log('existing file:', dir[0].path)
return dir[0].path
} catch {}
}
export const useFetchExistingFile = () => {
const serverId = useStore(store => store.settings.activeServerId)
return async (options: FetchExisingFileOptions) => fetchExistingFile(options, serverId)
}
function assertMimeType(expected?: string, actual?: string) {
expected = expected?.toLowerCase()
actual = actual?.toLowerCase()
if (!expected || expected === actual) {
return
}
if (!expected.includes(';')) {
actual = actual?.split(';')[0]
}
if (!expected.includes('/')) {
actual = actual?.split('/')[0]
}
if (expected !== actual) {
throw new Error(`Request does not satisfy expected content type. Expected: ${expected} Actual: ${actual}`)
}
}
export type FetchFileOptions = FetchExisingFileOptions & {
fromUrl: string
useCacheBuster?: boolean
expectedContentType?: string
progress?: (received: number, total: number) => void
}
export async function fetchFile(options: FetchFileOptions, serverId: string | undefined): Promise<string> {
let { itemType, itemId, fromUrl, useCacheBuster, expectedContentType, progress } = options
useCacheBuster = useCacheBuster === undefined ? true : useCacheBuster
const fileDir = cacheDir(serverId, itemType, itemId)
const filePathNoExt = path.join(fileDir, useCacheBuster ? useStore.getState().settings.cacheBuster : itemType)
try {
await RNFS.unlink(fileDir)
} catch {}
const headers = { 'User-Agent': userAgent }
// we send a HEAD first for two reasons:
// 1. to follow any redirects and get the actual URL (DownloadManager does not support redirects)
// 2. to obtain the mime-type up front so we can use it for the file extension/validation
const headRes = await fetch(fromUrl, { method: 'HEAD', headers })
if (headRes.status > 399) {
throw new Error(`HTTP status error ${headRes.status}. File: ${itemType} ID: ${itemId}`)
}
const contentType = headRes.headers.get('content-type') || undefined
assertMimeType(expectedContentType, contentType)
const contentDisposition = headRes.headers.get('content-disposition') || undefined
const filename = contentDisposition ? cd.parse(contentDisposition).parameters.filename : undefined
let extension: string | undefined
if (filename) {
extension = path.extname(filename) || undefined
if (extension) {
extension = extension.substring(1)
}
} else if (contentType) {
extension = mime.extension(contentType) || undefined
}
const config = ReactNativeBlobUtil.config({
addAndroidDownloads: {
useDownloadManager: true,
notification: false,
mime: contentType,
description: 'subtracks',
path: extension ? `${filePathNoExt}.${extension}` : filePathNoExt,
},
})
const fetchParams: Parameters<typeof config['fetch']> = ['GET', headRes.url, headers]
let res: FetchBlobResponse
if (progress) {
res = await config.fetch(...fetchParams).progress(progress)
} else {
res = await config.fetch(...fetchParams)
}
const downloadPath = res.path()
queryClient.setQueryData<string>(qk.existingFiles(itemType, itemId), downloadPath)
console.log('downloaded file:', downloadPath)
return downloadPath
}
export const useFetchFile = () => {
const serverId = useStore(store => store.settings.activeServerId)
return async (options: FetchFileOptions) => fetchFile(options, serverId)
}

View File

@ -1,7 +1,7 @@
import { CacheImageSize, CacheItemTypeKey } from '@app/models/cache' import { CacheImageSize, CacheItemTypeKey } from '@app/models/cache'
import { Album, Artist, Playlist, Song, StarrableItemType } from '@app/models/library' import { Album, Artist, Playlist, Song, StarrableItemType } from '@app/models/library'
import { CollectionById } from '@app/models/state' import { CollectionById } from '@app/models/state'
import queryClient from '@app/queryClient' import queryClient from '@app/query/queryClient'
import { useStore } from '@app/state/store' import { useStore } from '@app/state/store'
import { GetAlbumList2TypeBase, Search3Params, StarParams } from '@app/subsonic/params' import { GetAlbumList2TypeBase, Search3Params, StarParams } from '@app/subsonic/params'
import _ from 'lodash' import _ from 'lodash'
@ -13,16 +13,15 @@ import {
useFetchArtistInfo, useFetchArtistInfo,
useFetchArtists, useFetchArtists,
useFetchArtistTopSongs, useFetchArtistTopSongs,
useFetchExistingFile,
useFetchFile,
useFetchPlaylist, useFetchPlaylist,
useFetchPlaylists, useFetchPlaylists,
useFetchSearchResults, useFetchSearchResults,
useFetchSong, useFetchSong,
useFetchStar, useFetchStar,
useFetchUnstar, useFetchUnstar,
} from './fetch' } from '../query/fetch/api'
import qk from './queryKeys' import qk from '@app/query/queryKeys'
import { useFetchExistingFile, useFetchFile } from '@app/query/fetch/file'
export const useQueryArtists = () => useQuery(qk.artists, useFetchArtists()) export const useQueryArtists = () => useQuery(qk.artists, useFetchArtists())

View File

@ -1,10 +1,10 @@
import { useReset } from '@app/hooks/trackplayer' import { useReset } from '@app/hooks/trackplayer'
import { CacheItemTypeKey } from '@app/models/cache' 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 { 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 RNFS from 'react-native-fs'
import qk from './queryKeys' import qk from '@app/query/queryKeys'
export const useSwitchActiveServer = () => { export const useSwitchActiveServer = () => {
const activeServerId = useStore(store => store.settings.activeServerId) const activeServerId = useStore(store => store.settings.activeServerId)

View File

@ -1,12 +1,12 @@
import { Song } from '@app/models/library' import { Song } from '@app/models/library'
import { QueueContextType, TrackExt } from '@app/models/trackplayer' import { QueueContextType, TrackExt } from '@app/models/trackplayer'
import queryClient from '@app/queryClient' import queryClient from '@app/query/queryClient'
import queueService from '@app/queueservice' import QueueEvents from '@app/trackplayer/QueueEvents'
import { useStore, useStoreDeep } from '@app/state/store' import { useStore, useStoreDeep } from '@app/state/store'
import { getQueue, SetQueueOptions, trackPlayerCommands } from '@app/state/trackplayer' import { getQueue, SetQueueOptions, trackPlayerCommands } from '@app/state/trackplayer'
import userAgent from '@app/util/userAgent' import userAgent from '@app/util/userAgent'
import TrackPlayer from 'react-native-track-player' import TrackPlayer from 'react-native-track-player'
import qk from './queryKeys' import qk from '@app/query/queryKeys'
export const usePlay = () => { export const usePlay = () => {
return () => trackPlayerCommands.enqueue(() => TrackPlayer.play()) return () => trackPlayerCommands.enqueue(() => TrackPlayer.play())
@ -132,7 +132,7 @@ export const useSetQueue = (type: QueueContextType, songs?: Song[]) => {
} }
await _setQueue({ queue, type, contextId, ...options }) await _setQueue({ queue, type, contextId, ...options })
queueService.emit('set', { queue }) QueueEvents.emit('set', { queue })
} }
return { setQueue, contextId } return { setQueue, contextId }

15
app/hooks/useClient.ts Normal file
View File

@ -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

174
app/query/fetch/api.ts Normal file
View File

@ -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<T extends { id: string; starred?: undefined | any }>(item: T) {
queryClient.setQueryData<boolean>(qk.starredItems(item.id), !!item.starred)
}
function cacheAlbumCoverArtData<T extends { id: string; coverArt?: string }>(item: T) {
queryClient.setQueryData<string | undefined>(qk.albumCoverArt(item.id), item.coverArt)
}
export const useFetchArtists = () => {
const client = useClient()
return async () => {
const res = await client().getArtists()
res.data.artists.forEach(cacheStarredData)
return mapCollectionById(res.data.artists, mapArtist)
}
}
export const useFetchArtist = () => {
const client = useClient()
return async (id: string) => {
const res = await client().getArtist({ id })
cacheStarredData(res.data.artist)
res.data.albums.forEach(cacheStarredData)
res.data.albums.forEach(cacheAlbumCoverArtData)
return {
artist: mapArtist(res.data.artist),
albums: res.data.albums.map(mapAlbum),
}
}
}
export const useFetchArtistInfo = () => {
const client = useClient()
return async (id: string) => {
const res = await client().getArtistInfo2({ id })
return mapArtistInfo(id, res.data.artistInfo)
}
}
export const useFetchArtistTopSongs = () => {
const client = useClient()
return async (artistName: string) => {
const res = await client().getTopSongs({ artist: artistName })
res.data.songs.forEach(cacheStarredData)
return res.data.songs.map(mapSong)
}
}
export const useFetchPlaylists = () => {
const client = useClient()
return async () => {
const res = await client().getPlaylists()
return mapCollectionById(res.data.playlists, mapPlaylist)
}
}
export const useFetchPlaylist = () => {
const client = useClient()
return async (id: string): Promise<{ playlist: Playlist; songs?: Song[] }> => {
const res = await client().getPlaylist({ id })
res.data.playlist.songs.forEach(cacheStarredData)
return {
playlist: mapPlaylist(res.data.playlist),
songs: res.data.playlist.songs.map(mapSong),
}
}
}
export async function fetchAlbum(id: string, client: SubsonicApiClient): Promise<{ album: Album; songs?: Song[] }> {
const res = await client.getAlbum({ id })
cacheStarredData(res.data.album)
res.data.songs.forEach(cacheStarredData)
cacheAlbumCoverArtData(res.data.album)
return {
album: mapAlbum(res.data.album),
songs: res.data.songs.map(mapSong),
}
}
export const useFetchAlbum = () => {
const client = useClient()
return async (id: string) => fetchAlbum(id, client())
}
export const useFetchAlbumList = () => {
const client = useClient()
return async (size: number, offset: number, type: GetAlbumList2TypeBase) => {
const res = await client().getAlbumList2({ size, offset, type })
res.data.albums.forEach(cacheStarredData)
res.data.albums.forEach(cacheAlbumCoverArtData)
return res.data.albums.map(mapAlbum)
}
}
export const useFetchSong = () => {
const client = useClient()
return async (id: string) => {
const res = await client().getSong({ id })
cacheStarredData(res.data.song)
return mapSong(res.data.song)
}
}
export const useFetchSearchResults = () => {
const client = useClient()
return async (params: Search3Params) => {
const res = await client().search3(params)
res.data.artists.forEach(cacheStarredData)
res.data.albums.forEach(cacheStarredData)
res.data.songs.forEach(cacheStarredData)
res.data.albums.forEach(cacheAlbumCoverArtData)
return {
artists: res.data.artists.map(mapArtist),
albums: res.data.albums.map(mapAlbum),
songs: res.data.songs.map(mapSong),
}
}
}
export const useFetchStar = () => {
const client = useClient()
return async (params: StarParams) => {
await client().star(params)
return
}
}
export const useFetchUnstar = () => {
const client = useClient()
return async (params: StarParams) => {
await client().unstar(params)
return
}
}

132
app/query/fetch/file.ts Normal file
View File

@ -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<string | undefined> {
const { itemType, itemId } = options
const fileDir = cacheDir(serverId, itemType, itemId)
try {
const dir = await RNFS.readDir(fileDir)
console.log('existing file:', dir[0].path)
return dir[0].path
} catch {}
}
export const useFetchExistingFile = () => {
const serverId = useStore(store => store.settings.activeServerId)
return async (options: FetchExisingFileOptions) => fetchExistingFile(options, serverId)
}
function assertMimeType(expected?: string, actual?: string) {
expected = expected?.toLowerCase()
actual = actual?.toLowerCase()
if (!expected || expected === actual) {
return
}
if (!expected.includes(';')) {
actual = actual?.split(';')[0]
}
if (!expected.includes('/')) {
actual = actual?.split('/')[0]
}
if (expected !== actual) {
throw new Error(`Request does not satisfy expected content type. Expected: ${expected} Actual: ${actual}`)
}
}
export type FetchFileOptions = FetchExisingFileOptions & {
fromUrl: string
useCacheBuster?: boolean
expectedContentType?: string
progress?: (received: number, total: number) => void
}
export async function fetchFile(options: FetchFileOptions, serverId: string | undefined): Promise<string> {
let { itemType, itemId, fromUrl, useCacheBuster, expectedContentType, progress } = options
useCacheBuster = useCacheBuster === undefined ? true : useCacheBuster
const fileDir = cacheDir(serverId, itemType, itemId)
const filePathNoExt = path.join(fileDir, useCacheBuster ? useStore.getState().settings.cacheBuster : itemType)
try {
await RNFS.unlink(fileDir)
} catch {}
const headers = { 'User-Agent': userAgent }
// we send a HEAD first for two reasons:
// 1. to follow any redirects and get the actual URL (DownloadManager does not support redirects)
// 2. to obtain the mime-type up front so we can use it for the file extension/validation
const headRes = await fetch(fromUrl, { method: 'HEAD', headers })
if (headRes.status > 399) {
throw new Error(`HTTP status error ${headRes.status}. File: ${itemType} ID: ${itemId}`)
}
const contentType = headRes.headers.get('content-type') || undefined
assertMimeType(expectedContentType, contentType)
const contentDisposition = headRes.headers.get('content-disposition') || undefined
const filename = contentDisposition ? cd.parse(contentDisposition).parameters.filename : undefined
let extension: string | undefined
if (filename) {
extension = path.extname(filename) || undefined
if (extension) {
extension = extension.substring(1)
}
} else if (contentType) {
extension = mime.extension(contentType) || undefined
}
const config = ReactNativeBlobUtil.config({
addAndroidDownloads: {
useDownloadManager: true,
notification: false,
mime: contentType,
description: 'subtracks',
path: extension ? `${filePathNoExt}.${extension}` : filePathNoExt,
},
})
const fetchParams: Parameters<typeof config['fetch']> = ['GET', headRes.url, headers]
let res: FetchBlobResponse
if (progress) {
res = await config.fetch(...fetchParams).progress(progress)
} else {
res = await config.fetch(...fetchParams)
}
const downloadPath = res.path()
queryClient.setQueryData<string>(qk.existingFiles(itemType, itemId), downloadPath)
console.log('downloaded file:', downloadPath)
return downloadPath
}
export const useFetchFile = () => {
const serverId = useStore(store => store.settings.activeServerId)
return async (options: FetchFileOptions) => fetchFile(options, serverId)
}

View File

@ -1,6 +1,6 @@
import CoverArt from '@app/components/CoverArt' import CoverArt from '@app/components/CoverArt'
import HeaderBar from '@app/components/HeaderBar' 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 PressableOpacity from '@app/components/PressableOpacity'
import { PressableStar } from '@app/components/Star' import { PressableStar } from '@app/components/Star'
import { withSuspenseMemo } from '@app/components/withSuspense' import { withSuspenseMemo } from '@app/components/withSuspense'
@ -397,7 +397,7 @@ const NowPlayingView: React.FC<NowPlayingProps> = ({ navigation }) => {
return ( return (
<View style={styles.container}> <View style={styles.container}>
<ImageGradientBackground imagePath={imagePath} height={'100%'} /> <GradientImageBackground imagePath={imagePath} height={'100%'} />
<NowPlayingHeader track={track} /> <NowPlayingHeader track={track} />
<View style={styles.content}> <View style={styles.content}>
<SongCoverArt /> <SongCoverArt />

View File

@ -1,7 +1,7 @@
import CoverArt from '@app/components/CoverArt' import CoverArt from '@app/components/CoverArt'
import GradientBackground from '@app/components/GradientBackground' import GradientBackground from '@app/components/GradientBackground'
import HeaderBar from '@app/components/HeaderBar' 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 ListItem from '@app/components/ListItem'
import ListPlayerControls from '@app/components/ListPlayerControls' import ListPlayerControls from '@app/components/ListPlayerControls'
import NothingHere from '@app/components/NothingHere' import NothingHere from '@app/components/NothingHere'
@ -86,7 +86,7 @@ const SongListDetails = React.memo<{
title={title} title={title}
contextItem={songList.itemType === 'album' ? songList : undefined} contextItem={songList.itemType === 'album' ? songList : undefined}
/> />
<ImageGradientFlatList <GradientImageFlatList
data={_songs.map((s, i) => ({ data={_songs.map((s, i) => ({
song: s, song: s,
contextId, contextId,

View File

@ -1,8 +1,8 @@
/* eslint-disable no-dupe-class-members */ /* eslint-disable no-dupe-class-members */
import { EmitterSubscription, NativeEventEmitter } from 'react-native' 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: 'set', listener: (event: { queue: TrackExt[] }) => void): EmitterSubscription
addListener(eventType: string, listener: (event: any) => void, context?: Object): EmitterSubscription { addListener(eventType: string, listener: (event: any) => void, context?: Object): EmitterSubscription {
return super.addListener(eventType, listener, context) return super.addListener(eventType, listener, context)
@ -14,5 +14,5 @@ class QueueService extends NativeEventEmitter {
} }
} }
const queueService = new QueueService() const QueueEvents = new QueueEventEmitter()
export default queueService export default QueueEvents

View File

@ -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 { getCurrentTrack, getPlayerState, trackPlayerCommands } from '@app/state/trackplayer'
import NetInfo, { NetInfoStateType } from '@react-native-community/netinfo' import NetInfo, { NetInfoStateType } from '@react-native-community/netinfo'
import _ from 'lodash' import _ from 'lodash'
import { unstable_batchedUpdates } from 'react-native' import { unstable_batchedUpdates } from 'react-native'
import TrackPlayer, { Event, State } from 'react-native-track-player' import TrackPlayer, { Event, State } from 'react-native-track-player'
import { fetchAlbum, FetchExisingFileOptions, fetchExistingFile, fetchFile, FetchFileOptions } from './hooks/fetch' import queryClient from '../query/queryClient'
import qk from './hooks/queryKeys' import { useStore } from '../state/store'
import queryClient from './queryClient' import { ReturnedPromiseResolvedType } from '../util/types'
import queueService from './queueservice' import QueueEvents from './QueueEvents'
import { useStore } from './state/store'
import { ReturnedPromiseResolvedType } from './util/types'
const reset = () => { const reset = () => {
unstable_batchedUpdates(() => { 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 contextId = useStore.getState().queueContextId
const throwIfQueueChanged = () => { const throwIfQueueChanged = () => {
if (contextId !== useStore.getState().queueContextId) { if (contextId !== useStore.getState().queueContextId) {

View File

@ -4,7 +4,7 @@ import { CacheItemTypeKey } from '@app/models/cache'
const serversCacheDir = path.join(RNFS.ExternalDirectoryPath, 's') 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[] = [] const segments: string[] = []
serverId && segments.push(serverId) serverId && segments.push(serverId)
@ -13,3 +13,5 @@ export function cacheDir(serverId?: string, itemType?: CacheItemTypeKey, itemId?
return path.join(serversCacheDir, ...segments) return path.join(serversCacheDir, ...segments)
} }
export default cacheDir

View File

@ -13,7 +13,7 @@ LogBox.ignoreLogs([
import i18next from 'i18next' import i18next from 'i18next'
import { initReactI18next } from 'react-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' import * as RNLocalize from 'react-native-localize'
i18next.use(backend).use(languageDetector).use(initReactI18next).init({ 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' import TrackPlayer, { Capability } from 'react-native-track-player'
AppRegistry.registerComponent(appName, () => App) AppRegistry.registerComponent(appName, () => App)
TrackPlayer.registerPlaybackService(() => require('@app/playbackservice')) TrackPlayer.registerPlaybackService(() => require('@app/trackplayer/service'))
async function start() { async function start() {
await TrackPlayer.setupPlayer() await TrackPlayer.setupPlayer()