minor reorg/cleanup

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

View File

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

View File

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

View File

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

View File

@ -1,11 +1,13 @@
<img src="assets/header.png" alt="subtracks logo" width="500"/>
<img src=".assets/header.png" alt="subtracks logo" width="500"/>
#
Subtracks is an Android open source music streaming app for [Subsonic-API-compatible](http://www.subsonic.org/pages/api.jsp) servers ([Subsonic](http://www.subsonic.org/pages/index.jsp), [Navidrome](https://www.navidrome.org/), [Airsonic](https://airsonic.github.io/), and more). It's designed to give you clean and convenient access to your music in the style of modern media players.
#
Subtracks is an Android open source music streaming app for [Subsonic-compatible](http://www.subsonic.org/pages/api.jsp) servers ([Subsonic](http://www.subsonic.org/pages/index.jsp), [Navidrome](https://www.navidrome.org/), [Airsonic](https://airsonic.github.io/), and more). It's designed to give you clean and convenient access to your music in the style of modern media players.
[![Translation status](https://hosted.weblate.org/widgets/subtracks/-/subtracks/svg-badge.svg)](https://hosted.weblate.org/engage/subtracks/) ![GitHub Workflow Status (branch)](https://img.shields.io/github/workflow/status/austinried/subtracks/build-release-debugsign/main) ![GitHub release (latest SemVer)](https://img.shields.io/github/v/release/austinried/subtracks?label=github) ![F-Droid](https://img.shields.io/f-droid/v/com.subtracks)
# 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/">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,11 +1,11 @@
import ImageGradientBackground, { ImageGradientBackgroundProps } from '@app/components/ImageGradientBackground'
import GradientImageBackground, { GradientImageBackgroundProps } from '@app/components/GradientImageBackground'
import colors from '@app/styles/colors'
import 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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
import { CacheImageSize, CacheItemTypeKey } from '@app/models/cache'
import { 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())

View File

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

View File

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

@ -0,0 +1,15 @@
import { useStore } from '@app/state/store'
const useClient = () => {
const client = useStore(store => store.client)
return () => {
if (!client) {
throw new Error('no client!')
}
return client
}
}
export default useClient

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

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

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

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

View File

@ -1,6 +1,6 @@
import CoverArt from '@app/components/CoverArt'
import 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 />

View File

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

View File

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

View File

@ -1,14 +1,15 @@
import { fetchAlbum } from '@app/query/fetch/api'
import { FetchExisingFileOptions, fetchExistingFile, fetchFile, FetchFileOptions } from '@app/query/fetch/file'
import qk from '@app/query/queryKeys'
import { getCurrentTrack, getPlayerState, trackPlayerCommands } from '@app/state/trackplayer'
import 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) {

View File

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

View File

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