minor reorg/cleanup
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
@ -1,2 +0,0 @@
|
||||
BUNDLE_PATH: "vendor/bundle"
|
||||
BUNDLE_FORCE_RUBY_PLATFORM: 1
|
||||
19
README.md
@ -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.
|
||||
|
||||
[](https://hosted.weblate.org/engage/subtracks/)   
|
||||
|
||||
# Screenshots
|
||||
|
||||
<p float="left">
|
||||
<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"/>
|
||||
@ -14,15 +16,17 @@ Subtracks is an Android open source music streaming app for [Subsonic-API-compat
|
||||
</p>
|
||||
|
||||
# Download
|
||||
|
||||
<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://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://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://github.com/austinried/subtracks/releases/latest"><img src=".assets/github-badge.png" width="250"/></a>
|
||||
</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.
|
||||
|
||||
# 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!
|
||||
|
||||
<a href="https://hosted.weblate.org/engage/subtracks/">
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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<ItemT> = FlatListProps<ItemT> & {
|
||||
export type GradientBackgroundHeaderFlatListPropsBase<ItemT> = FlatListProps<ItemT> & {
|
||||
contentMarginTop?: number
|
||||
}
|
||||
|
||||
export type BackgroundHeaderFlatListProp<ItemT> = BackgroundHeaderFlatListPropsBase<ItemT> & {
|
||||
BackgroundComponent: typeof ImageGradientBackground | typeof GradientBackground
|
||||
backgroundProps?: ImageGradientBackgroundProps | GradientBackgroundProps
|
||||
export type GradientBackgroundHeaderFlatListProp<ItemT> = GradientBackgroundHeaderFlatListPropsBase<ItemT> & {
|
||||
BackgroundComponent: typeof GradientImageBackground | typeof GradientBackground
|
||||
backgroundProps?: GradientImageBackgroundProps | GradientBackgroundProps
|
||||
}
|
||||
|
||||
function BackgroundHeaderFlatList<ItemT>(props: BackgroundHeaderFlatListProp<ItemT>) {
|
||||
function GradientBackgroundHeaderFlatList<ItemT>(props: GradientBackgroundHeaderFlatListProp<ItemT>) {
|
||||
const window = useWindowDimensions()
|
||||
const headerLayout = useLayout()
|
||||
|
||||
@ -51,4 +51,4 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
})
|
||||
|
||||
export default BackgroundHeaderFlatList
|
||||
export default GradientBackgroundHeaderFlatList
|
||||
@ -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<ItemT> = BackgroundHeaderFlatListPropsBase<ItemT> & {
|
||||
export type GradientFlatListProps<ItemT> = GradientBackgroundHeaderFlatListPropsBase<ItemT> & {
|
||||
backgroundProps?: GradientBackgroundProps
|
||||
}
|
||||
|
||||
function GradientFlatList<ItemT>(props: GradientFlatListProps<ItemT>) {
|
||||
return <BackgroundHeaderFlatList BackgroundComponent={GradientBackground} {...props} />
|
||||
return <GradientBackgroundHeaderFlatList BackgroundComponent={GradientBackground} {...props} />
|
||||
}
|
||||
|
||||
export default GradientFlatList
|
||||
|
||||
@ -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<ImageGradientBackgroundProps> = ({
|
||||
const GradientImageBackground: React.FC<GradientImageBackgroundProps> = ({
|
||||
height,
|
||||
width,
|
||||
position,
|
||||
@ -83,4 +83,4 @@ const ImageGradientBackground: React.FC<ImageGradientBackgroundProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
export default ImageGradientBackground
|
||||
export default GradientImageBackground
|
||||
15
app/components/GradientImageFlatList.tsx
Normal 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
|
||||
@ -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<ScrollViewProps & ImageGradientBackgroundProps> = props => {
|
||||
const GradientImageScrollView: React.FC<ScrollViewProps & GradientImageBackgroundProps> = props => {
|
||||
const layout = useWindowDimensions()
|
||||
const paddingTop = useSafeAreaInsets().top
|
||||
|
||||
@ -22,10 +22,10 @@ const ImageGradientScrollView: React.FC<ScrollViewProps & ImageGradientBackgroun
|
||||
},
|
||||
]}
|
||||
contentContainerStyle={[{ minHeight }, props.contentContainerStyle]}>
|
||||
<ImageGradientBackground height={minHeight} imagePath={props.imagePath} onGetColor={props.onGetColor} />
|
||||
<GradientImageBackground height={minHeight} imagePath={props.imagePath} onGetColor={props.onGetColor} />
|
||||
{props.children}
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
|
||||
export default ImageGradientScrollView
|
||||
export default GradientImageScrollView
|
||||
@ -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
|
||||
@ -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)
|
||||
}
|
||||
@ -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())
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 }
|
||||
|
||||
15
app/hooks/useClient.ts
Normal 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
@ -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
@ -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)
|
||||
}
|
||||
@ -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<NowPlayingProps> = ({ navigation }) => {
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ImageGradientBackground imagePath={imagePath} height={'100%'} />
|
||||
<GradientImageBackground imagePath={imagePath} height={'100%'} />
|
||||
<NowPlayingHeader track={track} />
|
||||
<View style={styles.content}>
|
||||
<SongCoverArt />
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
<ImageGradientFlatList
|
||||
<GradientImageFlatList
|
||||
data={_songs.map((s, i) => ({
|
||||
song: s,
|
||||
contextId,
|
||||
|
||||
@ -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
|
||||
@ -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) {
|
||||
@ -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
|
||||
4
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()
|
||||
|
||||