mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-15 15:48:05 +00:00
feat: cache and download music
This commit is contained in:
@@ -7,6 +7,7 @@ import TrackPlayer, {
|
||||
usePlaybackState,
|
||||
useProgress,
|
||||
} from "react-native-track-player";
|
||||
import { audioStorageEvents, getLocalPath } from "@/providers/AudioStorage";
|
||||
import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
|
||||
|
||||
export const MusicPlaybackEngine: React.FC = () => {
|
||||
@@ -20,6 +21,7 @@ export const MusicPlaybackEngine: React.FC = () => {
|
||||
reportProgress,
|
||||
onTrackEnd,
|
||||
syncFromTrackPlayer,
|
||||
triggerLookahead,
|
||||
} = useMusicPlayer();
|
||||
|
||||
const lastReportedProgressRef = useRef(0);
|
||||
@@ -62,12 +64,52 @@ export const MusicPlaybackEngine: React.FC = () => {
|
||||
}
|
||||
}, [position, reportProgress]);
|
||||
|
||||
// Listen for track end
|
||||
// Listen for track changes (native -> JS)
|
||||
// This triggers look-ahead caching, checks for cached versions, and handles track end
|
||||
useEffect(() => {
|
||||
const subscription =
|
||||
TrackPlayer.addEventListener<PlaybackActiveTrackChangedEvent>(
|
||||
Event.PlaybackActiveTrackChanged,
|
||||
async (event) => {
|
||||
// Trigger look-ahead caching when a new track starts playing
|
||||
if (event.track) {
|
||||
triggerLookahead();
|
||||
|
||||
// Check if there's a cached version we should use instead
|
||||
const trackId = event.track.id;
|
||||
const currentUrl = event.track.url as string;
|
||||
|
||||
// Only check if currently using a remote URL
|
||||
if (trackId && currentUrl && !currentUrl.startsWith("file://")) {
|
||||
const cachedPath = getLocalPath(trackId);
|
||||
if (cachedPath) {
|
||||
console.log(
|
||||
`[AudioCache] Switching to cached version for ${trackId}`,
|
||||
);
|
||||
try {
|
||||
// Load the cached version, preserving position if any
|
||||
const currentIndex = await TrackPlayer.getActiveTrackIndex();
|
||||
if (currentIndex !== undefined && currentIndex >= 0) {
|
||||
const queue = await TrackPlayer.getQueue();
|
||||
const track = queue[currentIndex];
|
||||
// Remove and re-add with cached URL
|
||||
await TrackPlayer.remove(currentIndex);
|
||||
await TrackPlayer.add(
|
||||
{ ...track, url: cachedPath },
|
||||
currentIndex,
|
||||
);
|
||||
await TrackPlayer.skip(currentIndex);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[AudioCache] Failed to switch to cached version:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If there's no next track and the previous track ended, call onTrackEnd
|
||||
if (event.lastTrack && !event.track) {
|
||||
onTrackEnd();
|
||||
@@ -76,7 +118,54 @@ export const MusicPlaybackEngine: React.FC = () => {
|
||||
);
|
||||
|
||||
return () => subscription.remove();
|
||||
}, [onTrackEnd]);
|
||||
}, [onTrackEnd, triggerLookahead]);
|
||||
|
||||
// Listen for audio cache download completion and update queue URLs
|
||||
useEffect(() => {
|
||||
const onComplete = async ({
|
||||
itemId,
|
||||
localPath,
|
||||
}: {
|
||||
itemId: string;
|
||||
localPath: string;
|
||||
}) => {
|
||||
console.log(`[AudioCache] Track ${itemId} cached successfully`);
|
||||
|
||||
try {
|
||||
const queue = await TrackPlayer.getQueue();
|
||||
const currentIndex = await TrackPlayer.getActiveTrackIndex();
|
||||
|
||||
// Find the track in the queue
|
||||
const trackIndex = queue.findIndex((t) => t.id === itemId);
|
||||
|
||||
// Only update if track is in queue and not currently playing
|
||||
if (trackIndex >= 0 && trackIndex !== currentIndex) {
|
||||
const track = queue[trackIndex];
|
||||
const localUrl = localPath.startsWith("file://")
|
||||
? localPath
|
||||
: `file://${localPath}`;
|
||||
|
||||
// Skip if already using local URL
|
||||
if (track.url === localUrl) return;
|
||||
|
||||
console.log(
|
||||
`[AudioCache] Updating queue track ${trackIndex} to use cached file`,
|
||||
);
|
||||
|
||||
// Remove old track and insert updated one at same position
|
||||
await TrackPlayer.remove(trackIndex);
|
||||
await TrackPlayer.add({ ...track, url: localUrl }, trackIndex);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[AudioCache] Failed to update queue:", error);
|
||||
}
|
||||
};
|
||||
|
||||
audioStorageEvents.on("complete", onComplete);
|
||||
return () => {
|
||||
audioStorageEvents.off("complete", onComplete);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// No visual component needed - TrackPlayer is headless
|
||||
return null;
|
||||
|
||||
@@ -2,9 +2,16 @@ import { Ionicons } from "@expo/vector-icons";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image } from "expo-image";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { ActivityIndicator, TouchableOpacity, View } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { useNetworkStatus } from "@/hooks/useNetworkStatus";
|
||||
import {
|
||||
audioStorageEvents,
|
||||
getLocalPath,
|
||||
isPermanentDownloading,
|
||||
isPermanentlyDownloaded,
|
||||
} from "@/providers/AudioStorage";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
@@ -28,6 +35,7 @@ export const MusicTrackItem: React.FC<Props> = ({
|
||||
const [api] = useAtom(apiAtom);
|
||||
const { playTrack, currentTrack, isPlaying, loadingTrackId } =
|
||||
useMusicPlayer();
|
||||
const { isConnected, serverConnected } = useNetworkStatus();
|
||||
|
||||
const imageUrl = useMemo(() => {
|
||||
const albumId = track.AlbumId || track.ParentId;
|
||||
@@ -40,14 +48,61 @@ export const MusicTrackItem: React.FC<Props> = ({
|
||||
const isCurrentTrack = currentTrack?.Id === track.Id;
|
||||
const isTrackLoading = loadingTrackId === track.Id;
|
||||
|
||||
// Track download status with reactivity to completion events
|
||||
// Only track permanent downloads - we don't show UI for auto-caching
|
||||
const [downloadStatus, setDownloadStatus] = useState<
|
||||
"none" | "downloading" | "downloaded"
|
||||
>(() => {
|
||||
if (isPermanentlyDownloaded(track.Id)) return "downloaded";
|
||||
if (isPermanentDownloading(track.Id)) return "downloading";
|
||||
return "none";
|
||||
});
|
||||
|
||||
// Listen for download completion/error events (only for permanent downloads)
|
||||
useEffect(() => {
|
||||
const onComplete = (event: { itemId: string; permanent: boolean }) => {
|
||||
if (event.itemId === track.Id && event.permanent) {
|
||||
setDownloadStatus("downloaded");
|
||||
}
|
||||
};
|
||||
const onError = (event: { itemId: string }) => {
|
||||
if (event.itemId === track.Id) {
|
||||
setDownloadStatus("none");
|
||||
}
|
||||
};
|
||||
|
||||
audioStorageEvents.on("complete", onComplete);
|
||||
audioStorageEvents.on("error", onError);
|
||||
|
||||
return () => {
|
||||
audioStorageEvents.off("complete", onComplete);
|
||||
audioStorageEvents.off("error", onError);
|
||||
};
|
||||
}, [track.Id]);
|
||||
|
||||
// Also check periodically if permanent download started (for when download is triggered externally)
|
||||
useEffect(() => {
|
||||
if (downloadStatus === "none" && isPermanentDownloading(track.Id)) {
|
||||
setDownloadStatus("downloading");
|
||||
}
|
||||
});
|
||||
|
||||
const _isDownloaded = downloadStatus === "downloaded";
|
||||
// Check if available locally (either cached or permanently downloaded)
|
||||
const isAvailableLocally = !!getLocalPath(track.Id);
|
||||
// Consider offline if either no network connection OR server is unreachable
|
||||
const isOffline = !isConnected || serverConnected === false;
|
||||
const isUnavailableOffline = isOffline && !isAvailableLocally;
|
||||
|
||||
const duration = useMemo(() => {
|
||||
if (!track.RunTimeTicks) return "";
|
||||
return formatDuration(track.RunTimeTicks);
|
||||
}, [track.RunTimeTicks]);
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
if (isUnavailableOffline) return;
|
||||
playTrack(track, queue);
|
||||
}, [playTrack, track, queue]);
|
||||
}, [playTrack, track, queue, isUnavailableOffline]);
|
||||
|
||||
const handleLongPress = useCallback(() => {
|
||||
onOptionsPress?.(track);
|
||||
@@ -62,7 +117,9 @@ export const MusicTrackItem: React.FC<Props> = ({
|
||||
onPress={handlePress}
|
||||
onLongPress={handleLongPress}
|
||||
delayLongPress={300}
|
||||
disabled={isUnavailableOffline}
|
||||
className={`flex flex-row items-center py-3 ${isCurrentTrack ? "bg-purple-900/20" : ""}`}
|
||||
style={isUnavailableOffline ? { opacity: 0.5 } : undefined}
|
||||
>
|
||||
{index !== undefined && (
|
||||
<View className='w-8 items-center'>
|
||||
@@ -130,6 +187,23 @@ export const MusicTrackItem: React.FC<Props> = ({
|
||||
|
||||
<Text className='text-neutral-500 text-xs mr-2'>{duration}</Text>
|
||||
|
||||
{/* Download status indicator */}
|
||||
{downloadStatus === "downloading" && (
|
||||
<ActivityIndicator
|
||||
size={14}
|
||||
color='#9334E9'
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
)}
|
||||
{downloadStatus === "downloaded" && (
|
||||
<Ionicons
|
||||
name='checkmark-circle'
|
||||
size={16}
|
||||
color='#22c55e'
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{onOptionsPress && (
|
||||
<TouchableOpacity
|
||||
onPress={handleOptionsPress}
|
||||
|
||||
@@ -7,14 +7,34 @@ import {
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { Image } from "expo-image";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtom } from "jotai";
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet, TouchableOpacity, View } from "react-native";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||
import { useFavorite } from "@/hooks/useFavorite";
|
||||
import {
|
||||
downloadTrack,
|
||||
isCached,
|
||||
isPermanentDownloading,
|
||||
isPermanentlyDownloaded,
|
||||
} from "@/providers/AudioStorage";
|
||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||
import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
|
||||
import { getAudioStreamUrl } from "@/utils/jellyfin/audio/getAudioStreamUrl";
|
||||
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||
|
||||
interface Props {
|
||||
@@ -32,11 +52,30 @@ export const TrackOptionsSheet: React.FC<Props> = ({
|
||||
}) => {
|
||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||
const [api] = useAtom(apiAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const router = useRouter();
|
||||
const { playNext, addToQueue } = useMusicPlayer();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { t } = useTranslation();
|
||||
const [isDownloadingTrack, setIsDownloadingTrack] = useState(false);
|
||||
|
||||
const snapPoints = useMemo(() => ["45%"], []);
|
||||
// Use a placeholder item for useFavorite when track is null
|
||||
const { isFavorite, toggleFavorite } = useFavorite(
|
||||
track ?? ({ Id: "", UserData: { IsFavorite: false } } as BaseItemDto),
|
||||
);
|
||||
|
||||
const snapPoints = useMemo(() => ["65%"], []);
|
||||
|
||||
// Check download status
|
||||
const isAlreadyDownloaded = useMemo(
|
||||
() => isPermanentlyDownloaded(track?.Id),
|
||||
[track?.Id],
|
||||
);
|
||||
const isOnlyCached = useMemo(() => isCached(track?.Id), [track?.Id]);
|
||||
const isCurrentlyDownloading = useMemo(
|
||||
() => isPermanentDownloading(track?.Id),
|
||||
[track?.Id],
|
||||
);
|
||||
|
||||
const imageUrl = useMemo(() => {
|
||||
if (!track) return null;
|
||||
@@ -93,6 +132,55 @@ export const TrackOptionsSheet: React.FC<Props> = ({
|
||||
}, 300);
|
||||
}, [onAddToPlaylist, setOpen]);
|
||||
|
||||
const handleDownload = useCallback(async () => {
|
||||
if (!track?.Id || !api || !user?.Id || isAlreadyDownloaded) return;
|
||||
|
||||
setIsDownloadingTrack(true);
|
||||
try {
|
||||
const result = await getAudioStreamUrl(api, user.Id, track.Id);
|
||||
if (result?.url && !result.isTranscoding) {
|
||||
await downloadTrack(track.Id, result.url, { permanent: true });
|
||||
}
|
||||
} catch {
|
||||
// Silent fail
|
||||
}
|
||||
setIsDownloadingTrack(false);
|
||||
setOpen(false);
|
||||
}, [track?.Id, api, user?.Id, isAlreadyDownloaded, setOpen]);
|
||||
|
||||
const handleGoToArtist = useCallback(() => {
|
||||
const artistId = track?.ArtistItems?.[0]?.Id;
|
||||
if (artistId) {
|
||||
setOpen(false);
|
||||
router.push({
|
||||
pathname: "/music/artist/[artistId]",
|
||||
params: { artistId },
|
||||
});
|
||||
}
|
||||
}, [track?.ArtistItems, router, setOpen]);
|
||||
|
||||
const handleGoToAlbum = useCallback(() => {
|
||||
const albumId = track?.AlbumId || track?.ParentId;
|
||||
if (albumId) {
|
||||
setOpen(false);
|
||||
router.push({
|
||||
pathname: "/music/album/[albumId]",
|
||||
params: { albumId },
|
||||
});
|
||||
}
|
||||
}, [track?.AlbumId, track?.ParentId, router, setOpen]);
|
||||
|
||||
const handleToggleFavorite = useCallback(() => {
|
||||
if (track) {
|
||||
toggleFavorite();
|
||||
setOpen(false);
|
||||
}
|
||||
}, [track, toggleFavorite, setOpen]);
|
||||
|
||||
// Check if navigation options are available
|
||||
const hasArtist = !!track?.ArtistItems?.[0]?.Id;
|
||||
const hasAlbum = !!(track?.AlbumId || track?.ParentId);
|
||||
|
||||
if (!track) return null;
|
||||
|
||||
return (
|
||||
@@ -155,7 +243,7 @@ export const TrackOptionsSheet: React.FC<Props> = ({
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Options */}
|
||||
{/* Playback Options */}
|
||||
<View className='flex-col rounded-xl overflow-hidden bg-neutral-800'>
|
||||
<TouchableOpacity
|
||||
onPress={handlePlayNext}
|
||||
@@ -178,6 +266,25 @@ export const TrackOptionsSheet: React.FC<Props> = ({
|
||||
{t("music.track_options.add_to_queue")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Library Options */}
|
||||
<View className='flex-col rounded-xl overflow-hidden bg-neutral-800 mt-3'>
|
||||
<TouchableOpacity
|
||||
onPress={handleToggleFavorite}
|
||||
className='flex-row items-center px-4 py-3.5'
|
||||
>
|
||||
<Ionicons
|
||||
name={isFavorite ? "heart" : "heart-outline"}
|
||||
size={22}
|
||||
color={isFavorite ? "#ec4899" : "white"}
|
||||
/>
|
||||
<Text className='text-white ml-4 text-base'>
|
||||
{isFavorite
|
||||
? t("music.track_options.remove_from_favorites")
|
||||
: t("music.track_options.add_to_favorites")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.separator} />
|
||||
|
||||
@@ -190,7 +297,84 @@ export const TrackOptionsSheet: React.FC<Props> = ({
|
||||
{t("music.track_options.add_to_playlist")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.separator} />
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={handleDownload}
|
||||
disabled={
|
||||
isAlreadyDownloaded ||
|
||||
isCurrentlyDownloading ||
|
||||
isDownloadingTrack
|
||||
}
|
||||
className='flex-row items-center px-4 py-3.5'
|
||||
>
|
||||
{isCurrentlyDownloading || isDownloadingTrack ? (
|
||||
<ActivityIndicator size={22} color='white' />
|
||||
) : (
|
||||
<Ionicons
|
||||
name={
|
||||
isAlreadyDownloaded ? "checkmark-circle" : "download-outline"
|
||||
}
|
||||
size={22}
|
||||
color={isAlreadyDownloaded ? "#22c55e" : "white"}
|
||||
/>
|
||||
)}
|
||||
<Text
|
||||
className={`ml-4 text-base ${isAlreadyDownloaded ? "text-green-500" : "text-white"}`}
|
||||
>
|
||||
{isCurrentlyDownloading || isDownloadingTrack
|
||||
? t("music.track_options.downloading")
|
||||
: isAlreadyDownloaded
|
||||
? t("music.track_options.downloaded")
|
||||
: t("music.track_options.download")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{isOnlyCached && !isAlreadyDownloaded && (
|
||||
<>
|
||||
<View style={styles.separator} />
|
||||
<View className='flex-row items-center px-4 py-3.5'>
|
||||
<Ionicons name='cloud-done-outline' size={22} color='#737373' />
|
||||
<Text className='text-neutral-500 ml-4 text-base'>
|
||||
{t("music.track_options.cached")}
|
||||
</Text>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Navigation Options */}
|
||||
{(hasArtist || hasAlbum) && (
|
||||
<View className='flex-col rounded-xl overflow-hidden bg-neutral-800 mt-3'>
|
||||
{hasArtist && (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
onPress={handleGoToArtist}
|
||||
className='flex-row items-center px-4 py-3.5'
|
||||
>
|
||||
<Ionicons name='person-outline' size={22} color='white' />
|
||||
<Text className='text-white ml-4 text-base'>
|
||||
{t("music.track_options.go_to_artist")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
{hasAlbum && <View style={styles.separator} />}
|
||||
</>
|
||||
)}
|
||||
|
||||
{hasAlbum && (
|
||||
<TouchableOpacity
|
||||
onPress={handleGoToAlbum}
|
||||
className='flex-row items-center px-4 py-3.5'
|
||||
>
|
||||
<Ionicons name='disc-outline' size={22} color='white' />
|
||||
<Text className='text-white ml-4 text-base'>
|
||||
{t("music.track_options.go_to_album")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</BottomSheetView>
|
||||
</BottomSheetModal>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, View } from "react-native";
|
||||
import { toast } from "sonner-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { useHaptic } from "@/hooks/useHaptic";
|
||||
import {
|
||||
clearCache,
|
||||
clearPermanentDownloads,
|
||||
getStorageStats,
|
||||
} from "@/providers/AudioStorage";
|
||||
import { useDownload } from "@/providers/DownloadProvider";
|
||||
import { ListGroup } from "../list/ListGroup";
|
||||
import { ListItem } from "../list/ListItem";
|
||||
@@ -12,6 +18,7 @@ import { ListItem } from "../list/ListItem";
|
||||
export const StorageSettings = () => {
|
||||
const { deleteAllFiles, appSizeUsage } = useDownload();
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const successHapticFeedback = useHaptic("success");
|
||||
const errorHapticFeedback = useHaptic("error");
|
||||
|
||||
@@ -29,6 +36,11 @@ export const StorageSettings = () => {
|
||||
},
|
||||
});
|
||||
|
||||
const { data: musicCacheStats } = useQuery({
|
||||
queryKey: ["musicCacheStats"],
|
||||
queryFn: () => getStorageStats(),
|
||||
});
|
||||
|
||||
const onDeleteClicked = async () => {
|
||||
try {
|
||||
await deleteAllFiles();
|
||||
@@ -39,6 +51,32 @@ export const StorageSettings = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const onClearMusicCacheClicked = useCallback(async () => {
|
||||
try {
|
||||
await clearCache();
|
||||
queryClient.invalidateQueries({ queryKey: ["musicCacheStats"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["appSize"] });
|
||||
successHapticFeedback();
|
||||
toast.success(t("home.settings.storage.music_cache_cleared"));
|
||||
} catch (_e) {
|
||||
errorHapticFeedback();
|
||||
toast.error(t("home.settings.toasts.error_deleting_files"));
|
||||
}
|
||||
}, [queryClient, successHapticFeedback, errorHapticFeedback, t]);
|
||||
|
||||
const onDeleteDownloadedSongsClicked = useCallback(async () => {
|
||||
try {
|
||||
await clearPermanentDownloads();
|
||||
queryClient.invalidateQueries({ queryKey: ["musicCacheStats"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["appSize"] });
|
||||
successHapticFeedback();
|
||||
toast.success(t("home.settings.storage.downloaded_songs_deleted"));
|
||||
} catch (_e) {
|
||||
errorHapticFeedback();
|
||||
toast.error(t("home.settings.toasts.error_deleting_files"));
|
||||
}
|
||||
}, [queryClient, successHapticFeedback, errorHapticFeedback, t]);
|
||||
|
||||
const calculatePercentage = (value: number, total: number) => {
|
||||
return ((value / total) * 100).toFixed(2);
|
||||
};
|
||||
@@ -102,13 +140,41 @@ export const StorageSettings = () => {
|
||||
</View>
|
||||
</View>
|
||||
{!Platform.isTV && (
|
||||
<ListGroup>
|
||||
<ListItem
|
||||
textColor='red'
|
||||
onPress={onDeleteClicked}
|
||||
title={t("home.settings.storage.delete_all_downloaded_files")}
|
||||
/>
|
||||
</ListGroup>
|
||||
<>
|
||||
<ListGroup
|
||||
title={t("home.settings.storage.music_cache_title")}
|
||||
description={
|
||||
<Text className='text-[#8E8D91] text-xs'>
|
||||
{t("home.settings.storage.music_cache_description")}
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<ListItem
|
||||
onPress={onClearMusicCacheClicked}
|
||||
title={t("home.settings.storage.clear_music_cache")}
|
||||
subtitle={t("home.settings.storage.music_cache_size", {
|
||||
size: (musicCacheStats?.cacheSize ?? 0).bytesToReadable(),
|
||||
})}
|
||||
/>
|
||||
</ListGroup>
|
||||
<ListGroup>
|
||||
<ListItem
|
||||
textColor='red'
|
||||
onPress={onDeleteDownloadedSongsClicked}
|
||||
title={t("home.settings.storage.delete_all_downloaded_songs")}
|
||||
subtitle={t("home.settings.storage.downloaded_songs_size", {
|
||||
size: (musicCacheStats?.permanentSize ?? 0).bytesToReadable(),
|
||||
})}
|
||||
/>
|
||||
</ListGroup>
|
||||
<ListGroup>
|
||||
<ListItem
|
||||
textColor='red'
|
||||
onPress={onDeleteClicked}
|
||||
title={t("home.settings.storage.delete_all_downloaded_files")}
|
||||
/>
|
||||
</ListGroup>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user