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/)   
|
[](https://hosted.weblate.org/engage/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/">
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
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 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
|
||||||
@ -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 { 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())
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
@ -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 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 />
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
@ -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) {
|
||||||
@ -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
|
||||||
4
index.js
@ -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()
|
||||||
|
|||||||