diff --git a/App.js b/App.js index 58377d12..b34c7fc8 100755 --- a/App.js +++ b/App.js @@ -85,6 +85,14 @@ const App = () => { else global.isSongCaching = settings.isSongCaching }, [settings.isSongCaching]) + React.useEffect(() => { + global.maxCacheSize = settings.maxCacheSize + }, [settings.maxCacheSize]) + + React.useEffect(() => { + global.enableMaxCacheSize = settings.enableMaxCacheSize + }, [settings.enableMaxCacheSize]) + React.useEffect(() => { const sysLang = localeLang() diff --git a/app/components/settings/OptionInput.js b/app/components/settings/OptionInput.js index e851bc87..6947bda3 100644 --- a/app/components/settings/OptionInput.js +++ b/app/components/settings/OptionInput.js @@ -5,7 +5,7 @@ import { ThemeContext } from '~/contexts/theme'; import settingStyles from '~/styles/settings'; import size from '~/styles/size'; -const OptionInput = ({ title, placeholder, value, onChangeText, isPassword, autoComplete = 'off', inputMode = undefined, isLast = false, secureTextEntry = undefined }) => { +const OptionInput = ({ title, placeholder, value, onChangeText, isPassword, autoComplete = 'off', inputMode = undefined, isLast = false, secureTextEntry = undefined, disable = false }) => { const theme = React.useContext(ThemeContext) return ( @@ -16,6 +16,7 @@ const OptionInput = ({ title, placeholder, value, onChangeText, isPassword, auto flex: undefined, maxWidth: '70%', width: 'min-content', + opacity: disable ? 0.5 : 1, }]}>{title} { +const OptionText = ({ placeholder, value, onChangeText, isPassword, autoComplete = 'off', inputMode = undefined, isLast = false, secureTextEntry = undefined }) => { const theme = React.useContext(ThemeContext) return ( @@ -46,4 +46,4 @@ const OptionInput = ({ placeholder, value, onChangeText, isPassword, autoComplet ) } -export default OptionInput; \ No newline at end of file +export default OptionText \ No newline at end of file diff --git a/app/contexts/settings.js b/app/contexts/settings.js index 384067f5..4a206929 100644 --- a/app/contexts/settings.js +++ b/app/contexts/settings.js @@ -54,6 +54,8 @@ export const defaultSettings = { isSongCaching: false, cacheNextSong: 5, showCache: true, + enableMaxCacheSize: false, + maxCacheSize: 5, // Player settings streamFormat: 'raw', maxBitRate: 0, diff --git a/app/i18next/en.json b/app/i18next/en.json index cd6d68fb..eadfa7d8 100644 --- a/app/i18next/en.json +++ b/app/i18next/en.json @@ -76,11 +76,17 @@ "Show cached songs": "Show cached songs", "Cache next song": "Cache next song", "Cache next song description": "Auto download upcoming songs (default: 5)", + "Clear cache": "Clear cache", "Clear API cache": "Clear API cache", "Clear song cache": "Clear song cache", "Clear cache alert message": "Are you sure you want to clear the cache?", "Cache stats": "Cache stats", - "No cache": "No cache" + "No cache": "No cache", + "Cache size": "Cache size", + "Max cache size": "Max cache size", + "Enable max cache size": "Enable max cache size", + "Max cache size GB": "Max cache size (in GB)", + "Max cache size description": "Set a maximum size for the music cache. When this size is reached, the least recently cached music will be removed from the cache." }, "shares": { "No shares found": "No shares found", diff --git a/app/i18next/fr.json b/app/i18next/fr.json index 1e4c17f1..ae435077 100644 --- a/app/i18next/fr.json +++ b/app/i18next/fr.json @@ -62,9 +62,9 @@ }, "player": { "Stream format": "Format du flux", - "Stream format description": "Spécifiez le format du flux à lire.", + "Stream format Description": "Spécifiez le format du flux à lire.", "Max bitrate": "Débit maximal", - "Max bitrate description": "Spécifiez le débit maximal en kilobits par seconde pour le flux à lire. Des débits plus bas consommeront moins de données mais peuvent entraîner une qualité audio inférieure.", + "Max bitrate Description": "Spécifiez le débit maximal en kilobits par seconde pour le flux à lire. Des débits plus bas consommeront moins de données mais peuvent entraîner une qualité audio inférieure.", "Play seed first": "Lire la référence en premier" }, "theme": { @@ -76,11 +76,17 @@ "Show cached songs": "Afficher les musiques en cache", "Cache next song": "Musique suivante en cache", "Cache next song description": "Télécharger automatiquement les titres à venir (par défaut : 5)", + "Clear cache": "Vider le cache", "Clear API cache": "Vider le cache de l'API", "Clear song cache": "Vider le cache des musiques", "Clear cache alert message": "Êtes-vous sûr de vouloir vider le cache ?", "Cache stats": "Cache stats", - "No cache": "Aucun cache" + "No cache": "Aucun cache", + "Cache size": "Taille du cache", + "Max cache size": "Taille maximale du cache", + "Enable max cache size": "Activer la taille maximale du cache", + "Max cache size GB": "Taille maximale du cache (en Go)", + "Max cache size description": "Définissez une taille maximale pour le cache des musiques. Lorsque cette taille est atteinte, les musiques les moins récemment mis en cache seront supprimées du cache." }, "shares": { "No shares found": "Aucun partage trouvé", diff --git a/app/screens/Settings/Cache.js b/app/screens/Settings/Cache.js index 4ef40334..26ad4210 100644 --- a/app/screens/Settings/Cache.js +++ b/app/screens/Settings/Cache.js @@ -23,7 +23,6 @@ const CacheSettings = () => { const settings = React.useContext(SettingsContext) const setSettings = React.useContext(SetSettingsContext) const theme = React.useContext(ThemeContext) - const [cacheNextSong, setCacheNextSong] = React.useState(settings.cacheNextSong.toString()) const [statCache, setStatCache] = React.useState([ { name: 'Loading...', count: '' }, ]) @@ -39,17 +38,6 @@ const CacheSettings = () => { getStat() }, []) - React.useEffect(() => { - setCacheNextSong(settings.cacheNextSong.toString()) - }, [settings.cacheNextSong]) - - React.useEffect(() => { - if (cacheNextSong === '') return - const number = parseInt(cacheNextSong) - if (number === settings.cacheNextSong) return - setSettings({ ...settings, cacheNextSong: number }) - }, [cacheNextSong]) - return ( { /> setCacheNextSong(text.replace(/[^0-9]/g, ''))} + value={settings.cacheNextSong.toString()} + onChangeText={(text) => { + const cleanedText = text.replace(/[^0-9]/g, '') + const number = cleanedText === '' ? 0 : parseInt(cleanedText) + setSettings({ ...settings, cacheNextSong: number }) + }} inputMode="numeric" isLast /> {t('settings.cache.Cache next song description')} + {t('settings.cache.Max cache size')} + + setSettings({ ...settings, enableMaxCacheSize: !settings.enableMaxCacheSize })} + /> + { + const cleanedText = text.replace(/[^0-9]/g, '') + const number = cleanedText === '' ? 0 : parseInt(cleanedText) + setSettings({ ...settings, maxCacheSize: number }) + }} + inputMode="numeric" + disable={!settings.enableMaxCacheSize} + isLast + /> + + {t('settings.cache.Max cache size description')} + {t('settings.cache.Clear cache')} { logger.error('initCacheSong', error) }) global.listCacheSong = await getListCacheSong() || [] + global.sizeCacheSong = await FileSystem.getInfoAsync(getPathDir()) + .then(info => info.size) + .catch(() => 0) } // Cache Settings @@ -106,22 +109,48 @@ export const clearSongCache = async () => { export const getStatCache = async () => { return [ { - name: 'Cache Api', + name: 'Cache API', count: await AsyncStorage.getAllKeys() .then(keys => keys.filter(key => key.startsWith('http')).length) .catch(() => 0) }, { - name: 'Cache Songs', + name: 'Cache songs', count: await getListCacheSong() .then(files => files.length) .catch(() => 0) }, { - name: 'Cache Songs Size', + name: 'Cache songs vize', count: await FileSystem.getInfoAsync(getPathDir()) .then(info => `${(info.size / (1024 * 1024)) | 0 || 0} MB`) .catch(() => '0.00') }, ] } + +export const deleteOldCacheSongs = async () => { + const files = await FileSystem.readDirectoryAsync(getPathDir()) + + console.log('Current cached songs:', files) + files.sort((a, b) => { + return b.modificationTime - a.modificationTime + }) + + for (let i = 0; i < 5; i++) { + const file = files[i] + console.log('Deleting old cached song:', `${file}`) + await FileSystem.deleteAsync(`${getPathDir()}${file}`) + global.listCacheSong = global.listCacheSong.filter(f => f !== file) + } + // delete part + files.filter(file => file.endsWith('.part')).forEach(async (file) => { + console.log('Deleting part file:', file) + // await FileSystem.deleteAsync(`${getPathDir()}${file}`) + // global.listCacheSong = global.listCacheSong.filter(f => f !== file) + }) + + global.sizeCacheSong = await FileSystem.getInfoAsync(getPathDir()) + .then(info => info.size) + .catch(() => 0) +} diff --git a/app/utils/player.native.js b/app/utils/player.native.js index 1897888a..4a147cb7 100644 --- a/app/utils/player.native.js +++ b/app/utils/player.native.js @@ -3,7 +3,7 @@ import * as FileSystem from 'expo-file-system'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { urlCover, urlStream } from '~/utils/url'; -import { isSongCached, getPathSong } from '~/utils/cache'; +import { isSongCached, getPathSong, deleteOldCacheSongs } from '~/utils/cache'; import { nextRandomIndex, prevRandomIndex } from '~/utils/tools'; import logger from '~/utils/logger'; @@ -115,9 +115,13 @@ export const downloadSong = async (urlStream, id) => { if (global.songsDownloading.indexOf(id) >= 0) return urlStream const fileUri = getPathSong(id, global.streamFormat) const partUri = `${fileUri}.part` - global.songsDownloading.push(id) if (await isSongCached(null, id, global.streamFormat, global.maxBitRate)) return fileUri + if (global.enableMaxCacheSize && global.sizeCacheSong >= global.maxCacheSize * 1024 * 1024 * 1024) { + logger.info('downloadSong', 'Max cache size reached, skipping download') + return urlStream + } + global.songsDownloading.push(id) try { const res = await FileSystem.downloadAsync(urlStream, partUri) const contentType = getHeader(res?.headers, 'content-type') @@ -136,6 +140,7 @@ export const downloadSong = async (urlStream, id) => { } else { await FileSystem.moveAsync({ from: partUri, to: fileUri }) global.listCacheSong.push(`${id}.${global.streamFormat}`) + global.sizeCacheSong += realSize return fileUri } } catch (error) { @@ -148,6 +153,10 @@ export const downloadNextSong = async (queue, currentIndex) => { if (!global.isSongCaching) return const maxIndex = Math.min(global.cacheNextSong, queue.length) + if (global.enableMaxCacheSize && global.sizeCacheSong >= global.maxCacheSize * 1024 * 1024 * 1024) { + logger.info('downloadNextSong', 'Max cache size reached, deleting old cached songs') + await deleteOldCacheSongs() + } for (let i = -1; i < maxIndex; i++) { const index = (currentIndex + queue.length + i) % queue.length if (!queue[index].isLiveStream && queue[index].id.match(/^[a-zA-Z0-9-]*$/)) { diff --git a/package.json b/package.json index a635b073..947bd6be 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "web": "cross-env PLATFORM=web expo start --web", "export:web": "cross-env PLATFORM=web expo export -p web && workbox generateSW workbox-config.js", "eslint": "eslint app index.js App.js -c eslint.config.mjs", - "postinstall": "patch-package" + "postinstall": "patch-package", + "lang": "node ./scripts/VerifyLang.js" }, "dependencies": { "@legendapp/list": "^1.1.4", diff --git a/public/index.html b/public/index.html index ca424afb..e3df2a6d 100644 --- a/public/index.html +++ b/public/index.html @@ -7,7 +7,7 @@ %WEB_TITLE% - + diff --git a/public/manifest.json b/public/manifest.json index 9e533902..326d9ffa 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -1,7 +1,7 @@ { "name": "Castafiore", "short_name": "Castafiore", - "description": "Mobile app for navidrome", + "description": "Castafiore is a music player that support Navidrome and Subsonic API.", "lang": "en", "display": "standalone", "orientation": "portrait", diff --git a/scripts/VerifyLang.js b/scripts/VerifyLang.js new file mode 100644 index 00000000..2721329b --- /dev/null +++ b/scripts/VerifyLang.js @@ -0,0 +1,33 @@ +// script that check if language keys are translated in all files +const fs = require('fs') +const path = require('path') +const languagesDir = path.join(__dirname, '..', 'app', 'i18next') +const baseLanguageFile = path.join(languagesDir, 'en.json') + +const baseLanguage = JSON.parse(fs.readFileSync(baseLanguageFile, 'utf8')) + +const checkTranslations = (baseObj, compareObj, parentKey = '') => { + for (const key in baseObj) { + const fullKey = parentKey ? `${parentKey}.${key}` : key + if (typeof baseObj[key] === 'object' && baseObj[key] !== null) { + if (!(key in compareObj)) { + console.log(`Missing key: ${fullKey}`) + } else { + checkTranslations(baseObj[key], compareObj[key], fullKey) + } + } else { + if (!(key in compareObj)) { + console.log(`Missing key: ${fullKey}`) + } + } + } +} + +fs.readdirSync(languagesDir).forEach(file => { + if (file.endsWith('.json') && file !== 'en.json') { + const filePath = path.join(languagesDir, file) + const compareLanguage = JSON.parse(fs.readFileSync(filePath, 'utf8')) + console.log(`\nChecking translations for ${file}:`) + checkTranslations(baseLanguage, compareLanguage) + } +}) \ No newline at end of file