Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
## Support Feature
- Customize Home page
- Offline music
- Offline playlists (keep selected playlists on device)
- Favorited
- Playlist
- Radio
Expand All @@ -46,13 +47,26 @@
- Multi-languages
- Theme

## Offline playlists
On Android you can keep entire Navidrome/Subsonic playlists available offline:

- Open a playlist, long-press it (or use the options button) and choose **Keep offline**. Every song of the playlist is downloaded to the device.
- Kept playlists are marked with a cloud icon in the Playlists tab, and **re-synced automatically on launch** when a connection is available (new songs are downloaded, removed ones are pruned). When offline, the cached copy is left untouched.
- While songs download, each row shows the **download percentage**; once cached it shows a cloud icon. The download status is reactive and updates live.
- Opening a kept playlist shows the **disk space it occupies** next to the minutes/songs stats.
- Choose **Remove from offline** to unmark a playlist and free its songs (songs shared with another kept playlist are preserved).

> Note: offline playlist caching relies on the device file system and is available on **Android** only. The web build uses a service worker for caching instead.

## Install PWA on desktop
1. Open Google Chrome
2. Go to the [website](https://sawyerf.github.io/Castafiore/)
3. In the address bar, click the Install icon
4. Confirm by clicking Install

## Build locally
> A [`justfile`](https://github.com/casey/just) is provided as a shortcut for the commands below. Run `just` to list the available recipes (e.g. `just web`, `just lint`, `just build-android` for a local Gradle build).

### Web
If you want to build the web version, run the following command:
```bash
Expand Down
27 changes: 27 additions & 0 deletions app/components/OfflineSync.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import React from 'react'

import { useConfig } from '~/contexts/config'
import { useSettings, useSetSettings } from '~/contexts/settings'
import { syncCachedPlaylists } from '~/utils/offlineSync'

// Headless component: on launch (once config and settings are loaded) it
// re-downloads missing songs and prunes removed ones for every kept playlist.
// If the server is unreachable the sync leaves the cache untouched.
const OfflineSync = () => {
const config = useConfig()
const settings = useSettings()
const setSettings = useSetSettings()
const running = React.useRef(false)

React.useEffect(() => {
if (!config?.url || !settings.cachedPlaylists?.length) return
if (running.current) return
running.current = true
syncCachedPlaylists(config, settings, setSettings)
.finally(() => { running.current = false })
}, [config?.url, settings.cachedPlaylists])

return null
}

export default OfflineSync
2 changes: 2 additions & 0 deletions app/components/item/PlaylistItem.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { useConfig } from '~/contexts/config'
import { urlCover } from '~/utils/url'
import { useTheme } from '~/contexts/theme'
import { useSettings } from '~/contexts/settings'
import { isPlaylistCached } from '~/utils/offlineSync'
import ImageError from '~/components/ImageError'
import mainStyles from '~/styles/main'
import size from '~/styles/size'
Expand Down Expand Up @@ -49,6 +50,7 @@ const PlaylistItem = ({ playlist, index, setIndexOption }) => {
{(playlist.duration / 60) | 1} {t('min')} · {playlist.songCount} {t('songs')}
</Text>
</View>
{isPlaylistCached(settings, playlist.id) && <Icon name="cloud-download" size={size.icon.small} color={theme.secondaryText} style={{ paddingEnd: 5 }} />}
{playlist.comment?.includes(`#${config.username}-pin`) && <Icon name="bookmark" size={size.icon.small} color={theme.secondaryText} style={{ paddingEnd: 5 }} />}
</Pressable>
)
Expand Down
52 changes: 28 additions & 24 deletions app/components/item/SongItem.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import React from 'react'
import { Text, View, StyleSheet, Pressable } from 'react-native'
import { Text, View, StyleSheet, Pressable, ActivityIndicator } from 'react-native'
import Icon from 'react-native-vector-icons/FontAwesome'

import { useConfig } from '~/contexts/config'
import { isSongCached } from '~/utils/cache'
import { useDownloadProgress } from '~/utils/downloadStatus'
import { playSong } from '~/utils/player'
import { useSettings } from '~/contexts/settings'
import { useSongDispatch } from '~/contexts/song'
Expand All @@ -19,22 +20,25 @@ const Cached = ({ song }) => {
const theme = useTheme()
const settings = useSettings()
const config = useConfig()
const progress = useDownloadProgress(song.id)
const isDownloading = progress !== null

React.useEffect(() => {
cached(song)
.then((res) => {
setIsCached(res)
})
}, [song.id, settings.showCache])
let active = true
// Re-check whenever the download state flips so the icon updates live
// (e.g. progress -> cloud icon as soon as a download finishes).
isSongCached(config, song.id, settings.streamFormat, settings.maxBitrate)
.then((res) => { if (active) setIsCached(res) })
return () => { active = false }
}, [song.id, settings.streamFormat, settings.maxBitrate, isDownloading])

const cached = async (song) => {
if (!settings.showCache) return false
const cache = await isSongCached(config, song.id, settings.streamFormat, settings.maxBitrate)
if (cache) return true
return false
}

if (isCached) return (
if (isDownloading) return (
<View style={{ flexDirection: 'row', alignItems: 'center', paddingHorizontal: 5, gap: 5 }}>
<Text style={mainStyles.smallText(theme.secondaryText)}>{progress}%</Text>
<ActivityIndicator size="small" color={theme.secondaryText} />
</View>
)
if (isCached && settings.showCache) return (
<Icon
name="cloud-download"
size={14}
Expand Down Expand Up @@ -98,15 +102,15 @@ const SongItem = ({ song, queue, index, isIndex = false, isPlaying = false, setI
</Text>
) : null}
<Cached song={song} />
<FavoritedButton
id={song.id}
isFavorited={song?.starred}
rating={song?.userRating ?? song?.rating ?? 0}
style={{ padding: 5, paddingStart: 10 }}
/>
</Pressable>
)
}
<FavoritedButton
id={song.id}
isFavorited={song?.starred}
rating={song?.userRating ?? song?.rating ?? 0}
style={{ padding: 5, paddingStart: 10 }}
/>
</Pressable>
)
}

const styles = StyleSheet.create({
song: {
Expand All @@ -118,4 +122,4 @@ const styles = StyleSheet.create({
},
})

export default SongItem
export default SongItem
14 changes: 13 additions & 1 deletion app/components/options/OptionsPlaylist.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import { useConfig } from '~/contexts/config'
import { getApi } from '~/utils/api'
import { urlStream } from '~/utils/url'
import { downloadSong } from '~/utils/player'
import { useSettings } from '~/contexts/settings'
import { useSettings, useSetSettings } from '~/contexts/settings'
import { isPlaylistCached, addCachedPlaylist, removeCachedPlaylist } from '~/utils/offlineSync'
import OptionsPopup from '~/components/popup/OptionsPopup'

const OptionsPlaylist = ({ playlist, open, onClose, onRefresh }) => {
Expand All @@ -16,15 +17,26 @@ const OptionsPlaylist = ({ playlist, open, onClose, onRefresh }) => {
const config = useConfig()
const refOption = React.useRef()
const settings = useSettings()
const setSettings = useSetSettings()

if (!playlist) return null
const isCached = isPlaylistCached(settings, playlist.id)
return (
<OptionsPopup
ref={refOption}
visible={open}
close={onClose}
item={playlist}
options={[
...(Platform.OS !== 'web' ? [{
name: isCached ? t('Remove from offline') : t('Keep offline'),
icon: isCached ? 'check-circle' : 'cloud-download',
onPress: () => {
refOption.current.close()
if (isCached) removeCachedPlaylist(config, settings, setSettings, playlist.id)
else addCachedPlaylist(config, settings, setSettings, playlist)
}
}] : []),
{
name: t('Cache all songs'),
icon: 'cloud-download',
Expand Down
2 changes: 2 additions & 0 deletions app/contexts/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { SongProvider } from '~/contexts/song'
import { UpdateApiProvider } from '~/contexts/updateApi'
import { useSong, useSongDispatch } from '~/contexts/song'
import Player from '~/utils/player'
import OfflineSync from '~/components/OfflineSync'

const PlayerEvent = () => {
const song = useSong()
Expand All @@ -25,6 +26,7 @@ const AppProvider = ({ children }) => {
<UpdateApiProvider>
<RemoteProvider>
<PlayerEvent />
<OfflineSync />
{children}
</RemoteProvider>
</UpdateApiProvider>
Expand Down
2 changes: 2 additions & 0 deletions app/contexts/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ export const defaultSettings = {
isSongCaching: false,
cacheNextSong: 5,
showCache: true,
// Offline playlists kept on device: [{ id, name, songIds: [] }]
cachedPlaylists: [],
// Player settings
saveQueue: false,
streamFormat: 'raw',
Expand Down
2 changes: 2 additions & 0 deletions app/i18next/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,8 @@
"Artist": "Artist",
"Artists": "Artists",
"Cache all songs": "Cache all songs",
"Keep offline": "Keep offline",
"Remove from offline": "Remove from offline",
"Cache": "Cache",
"Cancel": "Cancel",
"Clear": "Clear",
Expand Down
2 changes: 2 additions & 0 deletions app/i18next/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@
"Artist": "Artista",
"Artists": "Artisti",
"Cache all songs": "Cache tutte le canzoni",
"Keep offline": "Tieni offline",
"Remove from offline": "Rimuovi da offline",
"Cache": "Cache",
"Cancel": "Annulla",
"Clear": "Svuota",
Expand Down
22 changes: 21 additions & 1 deletion app/screens/Pres/Playlist.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import { useCachedAndApi } from '~/utils/api'
import { urlCover } from '~/utils/url'
import { useSettings } from '~/contexts/settings'
import { useTheme } from '~/contexts/theme'
import { isPlaylistCached } from '~/utils/offlineSync'
import { getSongsCacheSize } from '~/utils/cache'
import { useDownloading } from '~/utils/downloadStatus'
import PresHeader from '~/components/PresHeader'
import mainStyles from '~/styles/main'
import OptionsSongsList from '~/components/options/OptionsSongsList'
Expand All @@ -25,13 +28,30 @@ const Playlist = ({ route: { params } }) => {
const [info, setInfo] = React.useState(null)
const [indexOptions, setIndexOptions] = React.useState(-1)
const [isOption, setIsOption] = React.useState(false)
const [cacheSize, setCacheSize] = React.useState(null)
const downloading = useDownloading()

const [songs, refresh] = useCachedAndApi([], 'getPlaylist', `id=${params.playlist.id}`, (json, setData) => {
setInfo(json?.playlist)
if (settings.reversePlaylist) setData(json?.playlist?.entry?.map((item, index) => ({ ...item, index })).reverse() || [])
else setData(json?.playlist?.entry?.map((item, index) => ({ ...item, index })) || [])
}, [params.playlist.id, settings.reversePlaylist])

const isCached = isPlaylistCached(settings, params.playlist.id)

React.useEffect(() => {
let active = true
if (!isCached || !songs.length) { setCacheSize(null); return }
// Recomputed when a download finishes (downloading set changes) so the
// size grows live while the playlist is being cached.
getSongsCacheSize(songs.map((song) => song.id), settings.streamFormat)
.then((bytes) => { if (active) setCacheSize(bytes) })
return () => { active = false }
}, [isCached, songs, settings.streamFormat, downloading])

const subTitle = `${((info?.duration || params?.playlist?.duration) / 60) | 1} ${t('minutes')} · ${info?.songCount || params?.playlist?.songCount} ${t('songs')}`
+ (cacheSize !== null ? ` · ${(cacheSize / (1024 * 1024)).toFixed(1)} MB` : '')

const renderItem = React.useCallback(({ item, index }) => (
<SongItem
song={item}
Expand All @@ -58,7 +78,7 @@ const Playlist = ({ route: { params } }) => {
<>
<PresHeader
title={info?.name || params.playlist.name}
subTitle={`${((info?.duration || params?.playlist?.duration) / 60) | 1} ${t('minutes')} · ${info?.songCount || params?.playlist?.songCount} ${t('songs')}`}
subTitle={subTitle}
imgSrc={urlCover(config, params.playlist)}
onPressOption={() => {
setIsOption(true)
Expand Down
10 changes: 10 additions & 0 deletions app/utils/cache.native.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,16 @@ export const getPathSong = (songId, streamFormat) => {
return `${getPathDir()}${songId}.${streamFormat}`
}

// Total size in bytes on disk of the given song ids that are cached.
export const getSongsCacheSize = async (songIds, streamFormat) => {
let total = 0
for (const id of songIds) {
const info = await FileSystem.getInfoAsync(getPathSong(id, streamFormat)).catch(() => null)
if (info?.exists) total += info.size || 0
}
return total
}


const getPathDir = () => {
return `${FileSystem.documentDirectory}/cache/${global.config.folderCache}/songs/`
Expand Down
4 changes: 4 additions & 0 deletions app/utils/cache.web.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ export const getPathSong = (_songId, _streamFormat) => {
return null
}

export const getSongsCacheSize = async (_songIds, _streamFormat) => {
return 0
}

export const initCacheSong = async () => {
if (global) global.listCacheSong = []
}
46 changes: 46 additions & 0 deletions app/utils/downloadStatus.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { useSyncExternalStore } from 'react'

// Reactive store of the songs currently being downloaded to disk, with their
// download progress (0..100). `global.songsDownloading` only grows (it dedupes
// attempted ids), so it can't tell what is in flight right now — this tracks the
// live set and notifies React.

const progress = new Map()
const listeners = new Set()
let snapshot = []

const emit = () => {
snapshot = Array.from(progress.keys())
listeners.forEach((listener) => listener())
}

export const markDownloading = (id) => {
if (progress.has(id)) return
progress.set(id, 0)
emit()
}

export const setProgress = (id, percent) => {
if (!progress.has(id) || progress.get(id) === percent) return
progress.set(id, percent)
emit()
}

export const markDownloaded = (id) => {
if (progress.delete(id)) emit()
}

const subscribe = (listener) => {
listeners.add(listener)
return () => listeners.delete(listener)
}

// List of song ids currently downloading (reactive).
export const useDownloading = () => useSyncExternalStore(subscribe, () => snapshot, () => snapshot)

// Download progress of a song id (0..100), or null if it is not downloading (reactive).
export const useDownloadProgress = (id) => useSyncExternalStore(
subscribe,
() => (progress.has(id) ? progress.get(id) : null),
() => (progress.has(id) ? progress.get(id) : null),
)
Loading