Skip to content
This repository was archived by the owner on Oct 4, 2023. It is now read-only.
9 changes: 9 additions & 0 deletions packages/common/src/models/Track.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,17 @@ export type TrackMetadata = {
// Added fields
dateListened?: string
duration: number

offline?: OfflineTrackMetadata
} & Timestamped

// This is available on mobile for offline tracks
export type OfflineTrackMetadata = {
downloaded_from_collection: string[]
download_completed_time: EpochTimeStamp
last_verified_time: EpochTimeStamp
}

export type Stem = {
track_id: ID
category: StemCategory
Expand Down
21 changes: 15 additions & 6 deletions packages/mobile/src/components/audio/Audio.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type { OnProgressData } from 'react-native-video'
import Video from 'react-native-video'
import { useDispatch, useSelector } from 'react-redux'

import { useOfflineTrackUri } from 'app/hooks/useOfflineTrack'
import { audiusBackendInstance } from 'app/services/audius-backend-instance'

import { useChromecast } from './GoogleCast'
Expand Down Expand Up @@ -330,6 +331,7 @@ export const Audio = () => {
progressInvalidator
]
)
const offlineTrackUri = useOfflineTrackUri(track)

if (!track || track.is_delete) return null

Expand All @@ -344,15 +346,22 @@ export const Audio = () => {
gateways
})

let source
if (offlineTrackUri) {
source = { uri: offlineTrackUri }
} else if (m3u8) {
source = {
uri: m3u8,
type: 'm3u8'
}
}

return (
<View style={styles.backgroundVideo}>
{m3u8 && (
{source && (
<Video
source={{
uri: m3u8,
// @ts-ignore: this is actually a valid prop override
type: 'm3u8'
}}
// @ts-ignore: type: m3u8 is actually a valid prop override
source={source}
ref={videoRef}
playInBackground
playWhenInactive
Expand Down
22 changes: 22 additions & 0 deletions packages/mobile/src/hooks/useOfflineTrack.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { Track } from '@audius/common'
import { FeatureFlags } from '@audius/common'
import { useAsync } from 'react-use'

import {
getLocalAudioPath,
isAudioAvailableOffline
} from 'app/services/offline-downloader'

import { useFeatureFlag } from './useRemoteConfig'

export const useOfflineTrackUri = (track: Track | null) => {
const { isEnabled: isOfflineModeEnabled } = useFeatureFlag(
FeatureFlags.OFFLINE_MODE_ENABLED
)
return useAsync(async () => {
if (!track || !isOfflineModeEnabled) return
if (!(await isAudioAvailableOffline(track))) return
const audioFilePath = getLocalAudioPath(track)
return `file://${audioFilePath}`
}, [track]).value
}
20 changes: 17 additions & 3 deletions packages/mobile/src/screens/favorites-screen/FavoritesScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { FeatureFlags } from '@audius/common'

import IconAlbum from 'app/assets/images/iconAlbum.svg'
import IconNote from 'app/assets/images/iconNote.svg'
import IconPlaylists from 'app/assets/images/iconPlaylists.svg'
import { Screen } from 'app/components/core'
import { Header } from 'app/components/header'
import { TopTabNavigator } from 'app/components/top-tab-bar'
import { usePopToTopOnDrawerOpen } from 'app/hooks/usePopToTopOnDrawerOpen'
import { useFeatureFlag } from 'app/hooks/useRemoteConfig'

import { ScreenContent } from '../ScreenContent'

Expand Down Expand Up @@ -32,13 +35,24 @@ const favoritesScreens = [

export const FavoritesScreen = () => {
usePopToTopOnDrawerOpen()
const { isEnabled: isOfflineModeEnabled } = useFeatureFlag(
FeatureFlags.OFFLINE_MODE_ENABLED
)

return (
<Screen>
<Header text='Favorites' />
<ScreenContent>
<TopTabNavigator screens={favoritesScreens} />
</ScreenContent>
{
// ScreenContent handles the offline indicator.
// Show favorites screen anyway when offline so users can see their downloads
isOfflineModeEnabled ? (
<TopTabNavigator screens={favoritesScreens} />
) : (
<ScreenContent>
<TopTabNavigator screens={favoritesScreens} />
</ScreenContent>
)
}
</Screen>
)
}
31 changes: 29 additions & 2 deletions packages/mobile/src/screens/favorites-screen/TracksTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useCallback, useState } from 'react'

import type { ID, UID } from '@audius/common'
import {
FeatureFlags,
useProxySelector,
savedPageActions,
playerSelectors,
Expand All @@ -17,12 +18,17 @@ import {
import { useFocusEffect } from '@react-navigation/native'
import { useDispatch, useSelector } from 'react-redux'

import { Tile, VirtualizedScrollView } from 'app/components/core'
import { Button, Tile, VirtualizedScrollView } from 'app/components/core'
import { EmptyTileCTA } from 'app/components/empty-tile-cta'
import { TrackList } from 'app/components/track-list'
import type { TrackMetadata } from 'app/components/track-list/types'
import { WithLoader } from 'app/components/with-loader/WithLoader'
import { useFeatureFlag } from 'app/hooks/useRemoteConfig'
import { make, track } from 'app/services/analytics'
import {
downloadTrack,
purgeAllDownloads
} from 'app/services/offline-downloader'
import { makeStyles } from 'app/styles'

import { FilterInput } from './FilterInput'
Expand Down Expand Up @@ -60,7 +66,9 @@ const getTracks = makeGetTableMetadatas(getSavedTracksLineup)
export const TracksTab = () => {
const dispatch = useDispatch()
const styles = useStyles()

const { isEnabled: isOfflineModeEnabled } = useFeatureFlag(
FeatureFlags.OFFLINE_MODE_ENABLED
)
const handleFetchSaves = useCallback(() => {
dispatch(fetchSaves())
}, [dispatch])
Expand All @@ -73,6 +81,13 @@ export const TracksTab = () => {
const savedTracksStatus = useSelector(getSavedTracksStatus)
const savedTracks = useProxySelector(getTracks, [])

const handleDownloadAllTracks = useCallback(() => {
if (!isOfflineModeEnabled) return
savedTracks.entries.forEach((track) => {
downloadTrack(track.track_id, 'favorites')
})
}, [isOfflineModeEnabled, savedTracks])

const filterTrack = (track: TrackMetadata) => {
const matchValue = filterValue?.toLowerCase()
return (
Expand Down Expand Up @@ -125,6 +140,18 @@ export const TracksTab = () => {
<EmptyTileCTA message={messages.emptyTabText} />
) : (
<>
{isOfflineModeEnabled ? (
<>
<Button
onPress={handleDownloadAllTracks}
title='Download All Favorites'
/>
<Button
onPress={purgeAllDownloads}
title='Purge All Downloads'
/>
</>
) : null}
<FilterInput
value={filterValue}
placeholder={messages.inputPlaceholder}
Expand Down
2 changes: 2 additions & 0 deletions packages/mobile/src/services/offline-downloader/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './offline-downloader'
export * from './offline-storage'
106 changes: 106 additions & 0 deletions packages/mobile/src/services/offline-downloader/offline-downloader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import path from 'path'

import type { Track, UserMetadata } from '@audius/common'
import {
encodeHashId,
accountSelectors,
cacheTracksSelectors
} from '@audius/common'
import { uniq } from 'lodash'
import RNFS, { exists } from 'react-native-fs'

import { store } from 'app/store'

import { apiClient } from '../audius-api-client'

import {
getLocalAudioPath,
getLocalCoverArtPath,
getLocalTrackJsonPath
} from './offline-storage'
const { getUserId } = accountSelectors
const { getTrack } = cacheTracksSelectors

/** Main entrypoint - perform all steps required to complete a download */
export const downloadTrack = async (trackId: number, collection: string) => {
const state = store.getState()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we are doing this anywhere else, all this logic feels like it might actually belong in sagas. But I'm pretty anti-saga these days so would love to hear other people's thoughts

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It felt weird writing it too. Currently, we usually call this from a component that has the state, but I wanted to keep it reusable for the potential background process. However, we won't be able to use the state their either. Keeping it simple for now, but open to ideas.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Discussed in standup. Seems like this is the way for now

const track = getTrack(state, { id: trackId })
if (!track) return false

await downloadCoverArt(track)
await tryDownloadTrackFromEachCreatorNode(track)
await writeTrackJson(track, collection)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do we do if either of these steps fail?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Really, we should check if all of the assets exist before writing downloaded_time to the track json. There are more steps that need to happen here, so I haven't gotten to that yet, but it's 100% something we need to worry about. I'll add a todo for the next round.

}

/** Unlike mp3 and album art, here we overwrite even if the file exists to ensure we have the latest */
const writeTrackJson = async (track: Track, collection: string) => {
const trackToWrite: Track = {
...track,
offline: {
downloaded_from_collection: uniq([
collection,
...(track?.offline?.downloaded_from_collection ?? [])
]),
download_completed_time: Date.now(),
last_verified_time: Date.now()
}
}

const pathToWrite = getLocalTrackJsonPath(track)
await RNFS.write(pathToWrite, JSON.stringify(trackToWrite))
}

const downloadCoverArt = async (track: Track) => {
// TODO: computed _cover_art_sizes isn't necessarily populated
const coverArtUris = Object.values(track._cover_art_sizes)
await Promise.all(
coverArtUris.map(async (coverArtUri) => {
const destination = getLocalCoverArtPath(track, coverArtUri)
await downloadIfNotExists(coverArtUri, destination)
})
)
}

const tryDownloadTrackFromEachCreatorNode = async (track: Track) => {
const state = store.getState()
const user = (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we not already have this in the store?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, we probably do. I was teaching myself how to use the API 😅

@amendelsohn amendelsohn Sep 29, 2022

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, wait. Not necessarily. If we're downloading all of our favorites we may not have loaded all of them yet. For now, yes, but in the future we will start this process whenever the app is open (and possibly in the background as well). I think I currently have a mix of logic meant to work in the background and logic that doesn't. It's going to need some further separation and cleanup.

await apiClient.getUser({
userId: track?.owner_id,
// @ts-ignore mismatch in an irrelevant part of state
currentUserId: getUserId(state)
})
)[0] as UserMetadata
const encodedTrackId = encodeHashId(track.track_id)
const creatorNodeEndpoints = user.creator_node_endpoint.split(',')
const destination = getLocalAudioPath(track)

for (const creatorNodeEndpoint of creatorNodeEndpoints) {
const uri = `${creatorNodeEndpoint}/tracks/stream/${encodedTrackId}`
const statusCode = await downloadIfNotExists(uri, destination)
if (statusCode) {
return statusCode
}
}
}

/** Dowanload file at uri to destination unless there is already a file at that location or overwrite is true */
const downloadIfNotExists = async (
Comment thread
amendelsohn marked this conversation as resolved.
uri: string,
destination: string,
overwrite?: boolean
) => {
if (!uri || !destination) return null
if (!overwrite && (await exists(destination))) {
return null
}

const destinationDirectory = path.dirname(destination)
await RNFS.mkdir(destinationDirectory)

const result = await RNFS.downloadFile({
fromUrl: uri,
toFile: destination
})?.promise

return result?.statusCode ?? null
}
77 changes: 77 additions & 0 deletions packages/mobile/src/services/offline-downloader/offline-storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import path from 'path'

import type { Track } from '@audius/common'
import RNFS, { exists } from 'react-native-fs'

export const downloadsRoot = path.join(RNFS.CachesDirectoryPath, 'downloads')

export const getPathFromRoot = (string: string) => {
return string.replace(downloadsRoot, '~')
}

export const getLocalTracksRoot = () => {
return path.join(downloadsRoot, `/tracks`)
}

export const getLocalTrackDir = (track: Track): string => {
return path.join(getLocalTracksRoot(), track.track_id.toString())
}

// Track Json

export const getLocalTrackJsonPath = (track: Track) => {
return path.join(getLocalTrackDir(track), `${track.track_id}.json`)
}

// Cover Art

export const getLocalCoverArtPath = (track: Track, uri: string) => {
return path.join(getLocalTrackDir(track), getArtFileNameFromUri(uri))
}

export const getArtFileNameFromUri = (uri: string) => {
// This should be "150x150.jpg" or similar
return uri.split('/').slice(-1)[0]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what if we just named them all the same thing like "Artwork"? (more predictable)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these will be named by the image size like 150x150.jpg

}

// Audio

export const getLocalAudioPath = (track: Track): string => {
return path.join(getLocalTrackDir(track), `${track.track_id}.mp3`)
}

export const isAudioAvailableOffline = async (track: Track) => {
return await exists(getLocalAudioPath(track))
}

// Storage management

/** Debugging method to clear all downloaded content */
export const purgeAllDownloads = async () => {
console.log(`Before purge:`)
await readDirRec(downloadsRoot)
await RNFS.unlink(downloadsRoot)
await RNFS.mkdir(downloadsRoot)
console.log(`After purge:`)
await readDirRec(downloadsRoot)
}

/** Debugging method to read cached files */
export const readDirRec = async (path: string) => {
const files = await RNFS.readDir(path)
if (files.length === 0) {
console.log(`${getPathFromRoot(path)} - empty`)
}
files.forEach((item) => {
if (item.isFile()) {
console.log(`${getPathFromRoot(item.path)} - ${item.size} bytes`)
}
})
await Promise.all(
files.map(async (item) => {
if (item.isDirectory()) {
await readDirRec(item.path)
}
})
)
}
3 changes: 3 additions & 0 deletions packages/mobile/src/utils/fileSystem.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const pathJoin = (...segments: (string | undefined)[]) => {
Comment thread
amendelsohn marked this conversation as resolved.
return segments.join('/')
}