From ab3465aec592bbe30bd0c50897fcb98c92bd7e74 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 4 Jan 2026 12:50:41 +0100 Subject: [PATCH] feat: cache and download music --- app/(auth)/(tabs)/(home)/_layout.tsx | 18 + app/(auth)/(tabs)/(home)/settings.tsx | 5 + .../(tabs)/(home)/settings/music/page.tsx | 75 ++ .../music/album/[albumId].tsx | 60 +- .../music/playlist/[playlistId].tsx | 57 +- .../music/[libraryId]/playlists.tsx | 1 - app/(auth)/now-playing.tsx | 8 +- app/_layout.tsx | 32 +- bun.lock | 12 +- components/music/MusicPlaybackEngine.tsx | 93 ++- components/music/MusicTrackItem.tsx | 78 ++- components/music/TrackOptionsSheet.tsx | 194 +++++- components/settings/StorageSettings.tsx | 82 ++- hooks/usePlaylistMutations.ts | 11 +- package.json | 2 + providers/AudioStorage/index.ts | 644 ++++++++++++++++++ providers/AudioStorage/types.ts | 41 ++ providers/MusicPlayerProvider.tsx | 200 ++++-- providers/NetworkStatusProvider.tsx | 3 +- translations/en.json | 30 +- utils/atoms/settings.ts | 12 + utils/jellyfin/audio/getAudioStreamUrl.ts | 68 ++ 22 files changed, 1616 insertions(+), 110 deletions(-) create mode 100644 app/(auth)/(tabs)/(home)/settings/music/page.tsx create mode 100644 providers/AudioStorage/index.ts create mode 100644 providers/AudioStorage/types.ts create mode 100644 utils/jellyfin/audio/getAudioStreamUrl.ts diff --git a/app/(auth)/(tabs)/(home)/_layout.tsx b/app/(auth)/(tabs)/(home)/_layout.tsx index 40a64413..ecd9ddbe 100644 --- a/app/(auth)/(tabs)/(home)/_layout.tsx +++ b/app/(auth)/(tabs)/(home)/_layout.tsx @@ -166,6 +166,24 @@ export default function IndexLayout() { ), }} /> + ( + _router.back()} + className='pl-0.5' + style={{ marginRight: Platform.OS === "android" ? 16 : 0 }} + > + + + ), + }} + /> + router.push("/settings/music/page")} + showArrow + title={t("home.settings.music.title")} + /> router.push("/settings/appearance/page")} showArrow diff --git a/app/(auth)/(tabs)/(home)/settings/music/page.tsx b/app/(auth)/(tabs)/(home)/settings/music/page.tsx new file mode 100644 index 00000000..bede0677 --- /dev/null +++ b/app/(auth)/(tabs)/(home)/settings/music/page.tsx @@ -0,0 +1,75 @@ +import { useTranslation } from "react-i18next"; +import { Platform, ScrollView, View } from "react-native"; +import { Switch } from "react-native-gesture-handler"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Text } from "@/components/common/Text"; +import { ListGroup } from "@/components/list/ListGroup"; +import { ListItem } from "@/components/list/ListItem"; +import { useSettings } from "@/utils/atoms/settings"; + +export default function MusicSettingsPage() { + const insets = useSafeAreaInsets(); + const { settings, updateSettings, pluginSettings } = useSettings(); + const { t } = useTranslation(); + + return ( + + + + {t("home.settings.music.playback_description")} + + } + > + + + updateSettings({ preferLocalAudio: value }) + } + /> + + + + + + {t("home.settings.music.caching_description")} + + } + > + + + updateSettings({ audioLookaheadEnabled: value }) + } + /> + + + + + + ); +} diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/music/album/[albumId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/music/album/[albumId].tsx index 3cbae3f6..3e4bbf44 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/music/album/[albumId].tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/music/album/[albumId].tsx @@ -8,7 +8,12 @@ import { useLocalSearchParams, useNavigation } from "expo-router"; import { useAtom } from "jotai"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Dimensions, TouchableOpacity, View } from "react-native"; +import { + ActivityIndicator, + Dimensions, + TouchableOpacity, + View, +} from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Text } from "@/components/common/Text"; import { Loader } from "@/components/Loader"; @@ -16,8 +21,13 @@ import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal"; import { MusicTrackItem } from "@/components/music/MusicTrackItem"; import { PlaylistPickerSheet } from "@/components/music/PlaylistPickerSheet"; import { TrackOptionsSheet } from "@/components/music/TrackOptionsSheet"; +import { + downloadTrack, + 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"; import { runtimeTicksToMinutes } from "@/utils/time"; @@ -37,6 +47,7 @@ export default function AlbumDetailScreen() { const [trackOptionsOpen, setTrackOptionsOpen] = useState(false); const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false); const [createPlaylistOpen, setCreatePlaylistOpen] = useState(false); + const [isDownloading, setIsDownloading] = useState(false); const handleTrackOptionsPress = useCallback((track: BaseItemDto) => { setSelectedTrack(track); @@ -113,6 +124,30 @@ export default function AlbumDetailScreen() { } }, [playQueue, tracks]); + // Check if all tracks are already permanently downloaded + const allTracksDownloaded = useMemo(() => { + if (!tracks || tracks.length === 0) return false; + return tracks.every((track) => isPermanentlyDownloaded(track.Id)); + }, [tracks]); + + const handleDownloadAlbum = useCallback(async () => { + if (!tracks || !api || !user?.Id || isDownloading) return; + + setIsDownloading(true); + try { + for (const track of tracks) { + if (!track.Id || isPermanentlyDownloaded(track.Id)) continue; + 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 + } + setIsDownloading(false); + }, [tracks, api, user?.Id, isDownloading]); + const isLoading = loadingAlbum || loadingTracks; if (isLoading) { @@ -184,7 +219,7 @@ export default function AlbumDetailScreen() { {/* Play buttons */} - + {t("music.shuffle")} + + {isDownloading ? ( + + ) : ( + + )} + } diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/music/playlist/[playlistId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/music/playlist/[playlistId].tsx index 20a5b600..ab32a235 100644 --- a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/music/playlist/[playlistId].tsx +++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/music/playlist/[playlistId].tsx @@ -8,7 +8,12 @@ import { useLocalSearchParams, useNavigation } from "expo-router"; import { useAtom } from "jotai"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Dimensions, TouchableOpacity, View } from "react-native"; +import { + ActivityIndicator, + Dimensions, + TouchableOpacity, + View, +} from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Text } from "@/components/common/Text"; import { Loader } from "@/components/Loader"; @@ -16,8 +21,10 @@ import { CreatePlaylistModal } from "@/components/music/CreatePlaylistModal"; import { MusicTrackItem } from "@/components/music/MusicTrackItem"; import { PlaylistPickerSheet } from "@/components/music/PlaylistPickerSheet"; import { TrackOptionsSheet } from "@/components/music/TrackOptionsSheet"; +import { downloadTrack, getLocalPath } 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"; import { runtimeTicksToMinutes } from "@/utils/time"; @@ -37,6 +44,7 @@ export default function PlaylistDetailScreen() { const [trackOptionsOpen, setTrackOptionsOpen] = useState(false); const [playlistPickerOpen, setPlaylistPickerOpen] = useState(false); const [createPlaylistOpen, setCreatePlaylistOpen] = useState(false); + const [isDownloading, setIsDownloading] = useState(false); const handleTrackOptionsPress = useCallback((track: BaseItemDto) => { setSelectedTrack(track); @@ -111,6 +119,30 @@ export default function PlaylistDetailScreen() { } }, [playQueue, tracks]); + // Check if all tracks are already downloaded + const allTracksDownloaded = useMemo(() => { + if (!tracks || tracks.length === 0) return false; + return tracks.every((track) => !!getLocalPath(track.Id)); + }, [tracks]); + + const handleDownloadPlaylist = useCallback(async () => { + if (!tracks || !api || !user?.Id || isDownloading) return; + + setIsDownloading(true); + try { + for (const track of tracks) { + if (!track.Id || getLocalPath(track.Id)) continue; + 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 + } + setIsDownloading(false); + }, [tracks, api, user?.Id, isDownloading]); + const isLoading = loadingPlaylist || loadingTracks; if (isLoading) { @@ -180,7 +212,7 @@ export default function PlaylistDetailScreen() { {/* Play buttons */} - + {t("music.shuffle")} + + {isDownloading ? ( + + ) : ( + + )} + } diff --git a/app/(auth)/(tabs)/(libraries)/music/[libraryId]/playlists.tsx b/app/(auth)/(tabs)/(libraries)/music/[libraryId]/playlists.tsx index 686d5a62..2afdd116 100644 --- a/app/(auth)/(tabs)/(libraries)/music/[libraryId]/playlists.tsx +++ b/app/(auth)/(tabs)/(libraries)/music/[libraryId]/playlists.tsx @@ -67,7 +67,6 @@ export default function PlaylistsScreen() { queryFn: async ({ pageParam = 0 }) => { const response = await getItemsApi(api!).getItems({ userId: user?.Id, - parentId: libraryId, includeItemTypes: ["Playlist"], sortBy: ["SortName"], sortOrder: ["Ascending"], diff --git a/app/(auth)/now-playing.tsx b/app/(auth)/now-playing.tsx index a9b045af..be1df280 100644 --- a/app/(auth)/now-playing.tsx +++ b/app/(auth)/now-playing.tsx @@ -411,7 +411,7 @@ const PlayerView: React.FC = ({ {/* Main Controls */} - + = ({ {/* Shuffle & Repeat Controls */} - + = ({ color={repeatMode !== "off" ? "#9334E9" : "#666"} /> {repeatMode === "one" && ( - + 1 )} @@ -474,7 +474,7 @@ const PlayerView: React.FC = ({ {/* Queue info */} {queue.length > 1 && ( - + {queueIndex + 1} of {queue.length} diff --git a/app/_layout.tsx b/app/_layout.tsx index e5cc5ec1..788a4f6c 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -2,7 +2,9 @@ import "@/augmentations"; import { ActionSheetProvider } from "@expo/react-native-action-sheet"; import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"; import { DarkTheme, ThemeProvider } from "@react-navigation/native"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister"; +import { QueryClient } from "@tanstack/react-query"; +import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client"; import * as BackgroundTask from "expo-background-task"; import * as Device from "expo-device"; import { Platform } from "react-native"; @@ -188,11 +190,21 @@ export default function RootLayout() { const queryClient = new QueryClient({ defaultOptions: { queries: { - staleTime: 30000, + staleTime: 30000, // 30 seconds - data is fresh + gcTime: 1000 * 60 * 60 * 24, // 24 hours - keep in cache for persistence }, }, }); +// Create MMKV-based persister for offline support +const mmkvPersister = createSyncStoragePersister({ + storage: { + getItem: (key) => storage.getString(key) ?? null, + setItem: (key, value) => storage.set(key, value), + removeItem: (key) => storage.remove(key), + }, +}); + function Layout() { const { settings } = useSettings(); const [user] = useAtom(userAtom); @@ -338,7 +350,19 @@ function Layout() { }, [user]); return ( - + { + // Only persist successful queries + return query.state.status === "success"; + }, + }, + }} + > @@ -410,6 +434,6 @@ function Layout() { - + ); } diff --git a/bun.lock b/bun.lock index 6b714632..0686dee9 100644 --- a/bun.lock +++ b/bun.lock @@ -16,7 +16,9 @@ "@react-navigation/material-top-tabs": "7.4.9", "@react-navigation/native": "^7.0.14", "@shopify/flash-list": "2.0.2", + "@tanstack/query-sync-storage-persister": "^5.90.18", "@tanstack/react-query": "5.90.12", + "@tanstack/react-query-persist-client": "^5.90.18", "axios": "^1.7.9", "expo": "~54.0.30", "expo-application": "~7.0.8", @@ -581,10 +583,16 @@ "@sinonjs/fake-timers": ["@sinonjs/fake-timers@10.3.0", "", { "dependencies": { "@sinonjs/commons": "^3.0.0" } }, "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA=="], - "@tanstack/query-core": ["@tanstack/query-core@5.90.12", "", {}, "sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg=="], + "@tanstack/query-core": ["@tanstack/query-core@5.90.16", "", {}, "sha512-MvtWckSVufs/ja463/K4PyJeqT+HMlJWtw6PrCpywznd2NSgO3m4KwO9RqbFqGg6iDE8vVMFWMeQI4Io3eEYww=="], + + "@tanstack/query-persist-client-core": ["@tanstack/query-persist-client-core@5.91.15", "", { "dependencies": { "@tanstack/query-core": "5.90.16" } }, "sha512-vnPSfQVo41EKJN8v20nkhWNZPyB1dMJIy5icOvCGzcCJzsmRefYY1owtr63ICOcjOiPPTuNEfPsdjdBhkzYnmA=="], + + "@tanstack/query-sync-storage-persister": ["@tanstack/query-sync-storage-persister@5.90.18", "", { "dependencies": { "@tanstack/query-core": "5.90.16", "@tanstack/query-persist-client-core": "5.91.15" } }, "sha512-tKngFopz/TuAe7LBDg7IOhWPh9blxdQ6QG/vVL2dFzRmlPNcSo4WdCSONqSDioJkcyTwh1YCSlcikmJ1WnSb3Q=="], "@tanstack/react-query": ["@tanstack/react-query@5.90.12", "", { "dependencies": { "@tanstack/query-core": "5.90.12" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg=="], + "@tanstack/react-query-persist-client": ["@tanstack/react-query-persist-client@5.90.18", "", { "dependencies": { "@tanstack/query-persist-client-core": "5.91.15" }, "peerDependencies": { "@tanstack/react-query": "^5.90.16", "react": "^18 || ^19" } }, "sha512-ToVRTVpjzTrd9S/p7JIvGdLs+Xtz9aDMM/7+TQGSV9notY8Jt64irfAAAkZ05syftLKS+3KPgyKAnHcVeKVbWQ=="], + "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], @@ -2133,6 +2141,8 @@ "@react-navigation/native-stack/color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="], + "@tanstack/react-query/@tanstack/query-core": ["@tanstack/query-core@5.90.12", "", {}, "sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg=="], + "accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], "ansi-fragments/colorette": ["colorette@1.4.0", "", {}, "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g=="], diff --git a/components/music/MusicPlaybackEngine.tsx b/components/music/MusicPlaybackEngine.tsx index 09f47372..0a1298c8 100644 --- a/components/music/MusicPlaybackEngine.tsx +++ b/components/music/MusicPlaybackEngine.tsx @@ -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( 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; diff --git a/components/music/MusicTrackItem.tsx b/components/music/MusicTrackItem.tsx index add4350e..190b52ad 100644 --- a/components/music/MusicTrackItem.tsx +++ b/components/music/MusicTrackItem.tsx @@ -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 = ({ 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 = ({ 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 = ({ 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 && ( @@ -130,6 +187,23 @@ export const MusicTrackItem: React.FC = ({ {duration} + {/* Download status indicator */} + {downloadStatus === "downloading" && ( + + )} + {downloadStatus === "downloaded" && ( + + )} + {onOptionsPress && ( = ({ }) => { const bottomSheetModalRef = useRef(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 = ({ }, 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 = ({ - {/* Options */} + {/* Playback Options */} = ({ {t("music.track_options.add_to_queue")} + + + {/* Library Options */} + + + + + {isFavorite + ? t("music.track_options.remove_from_favorites") + : t("music.track_options.add_to_favorites")} + + @@ -190,7 +297,84 @@ export const TrackOptionsSheet: React.FC = ({ {t("music.track_options.add_to_playlist")} + + + + + {isCurrentlyDownloading || isDownloadingTrack ? ( + + ) : ( + + )} + + {isCurrentlyDownloading || isDownloadingTrack + ? t("music.track_options.downloading") + : isAlreadyDownloaded + ? t("music.track_options.downloaded") + : t("music.track_options.download")} + + + + {isOnlyCached && !isAlreadyDownloaded && ( + <> + + + + + {t("music.track_options.cached")} + + + + )} + + {/* Navigation Options */} + {(hasArtist || hasAlbum) && ( + + {hasArtist && ( + <> + + + + {t("music.track_options.go_to_artist")} + + + {hasAlbum && } + + )} + + {hasAlbum && ( + + + + {t("music.track_options.go_to_album")} + + + )} + + )} ); diff --git a/components/settings/StorageSettings.tsx b/components/settings/StorageSettings.tsx index 117152fc..0c4c73cc 100644 --- a/components/settings/StorageSettings.tsx +++ b/components/settings/StorageSettings.tsx @@ -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 = () => { {!Platform.isTV && ( - - - + <> + + {t("home.settings.storage.music_cache_description")} + + } + > + + + + + + + + + )} ); diff --git a/hooks/usePlaylistMutations.ts b/hooks/usePlaylistMutations.ts index 30742427..65e83e77 100644 --- a/hooks/usePlaylistMutations.ts +++ b/hooks/usePlaylistMutations.ts @@ -27,10 +27,12 @@ export const useCreatePlaylist = () => { } const response = await getPlaylistsApi(api).createPlaylist({ - name, - ids: trackIds, - userId: user.Id, - mediaType: "Audio", + createPlaylistDto: { + Name: name, + Ids: trackIds, + UserId: user.Id, + MediaType: "Audio", + }, }); return response.data.Id; @@ -38,6 +40,7 @@ export const useCreatePlaylist = () => { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["music-playlists"], + refetchType: "all", }); toast.success(t("music.playlists.created")); }, diff --git a/package.json b/package.json index fa28a72c..dce3c756 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,9 @@ "@react-navigation/material-top-tabs": "7.4.9", "@react-navigation/native": "^7.0.14", "@shopify/flash-list": "2.0.2", + "@tanstack/query-sync-storage-persister": "^5.90.18", "@tanstack/react-query": "5.90.12", + "@tanstack/react-query-persist-client": "^5.90.18", "axios": "^1.7.9", "expo": "~54.0.30", "expo-application": "~7.0.8", diff --git a/providers/AudioStorage/index.ts b/providers/AudioStorage/index.ts new file mode 100644 index 00000000..8b9f50d3 --- /dev/null +++ b/providers/AudioStorage/index.ts @@ -0,0 +1,644 @@ +/** + * Audio Storage Module + * + * Unified storage manager for audio files supporting: + * - Look-ahead cache (auto-managed, ephemeral, stored in cache directory) + * - Future: Full music downloads (user-initiated, permanent, stored in documents) + * + * getLocalPath() checks permanent storage first, then cache. + */ + +import { EventEmitter } from "eventemitter3"; +import { Directory, File, Paths } from "expo-file-system"; +import type { EventSubscription } from "expo-modules-core"; +import type { + DownloadCompleteEvent as BGDownloadCompleteEvent, + DownloadErrorEvent as BGDownloadErrorEvent, +} from "@/modules"; +import { BackgroundDownloader } from "@/modules"; +import { storage } from "@/utils/mmkv"; +import type { + AudioStorageIndex, + DownloadCompleteEvent, + DownloadErrorEvent, + DownloadOptions, + StoredTrackInfo, +} from "./types"; + +// Storage keys +const AUDIO_STORAGE_INDEX_KEY = "audio_storage.v1.json"; + +// Directory names +const AUDIO_CACHE_DIR = "streamyfin-audio-cache"; +const AUDIO_PERMANENT_DIR = "streamyfin-audio"; + +// Default limits +const DEFAULT_MAX_CACHE_TRACKS = 10; +const DEFAULT_MAX_CACHE_SIZE_BYTES = 100 * 1024 * 1024; // 100MB + +// Event emitter for notifying about download completion +class AudioStorageEventEmitter extends EventEmitter<{ + complete: (event: DownloadCompleteEvent) => void; + error: (event: DownloadErrorEvent) => void; +}> {} + +export const audioStorageEvents = new AudioStorageEventEmitter(); + +// Track active downloads: taskId -> { itemId, permanent } +const activeDownloads = new Map< + number, + { itemId: string; permanent: boolean } +>(); + +// Track items being downloaded by itemId for quick lookup +const downloadingItems = new Set(); + +// Track permanent downloads separately for UI indicator +const permanentDownloadingItems = new Set(); + +// Cached index (loaded from storage on init) +let storageIndex: AudioStorageIndex | null = null; + +// Directories (initialized on first use) +let cacheDir: Directory | null = null; +let permanentDir: Directory | null = null; + +// Event listener subscriptions (for cleanup) +let _completeSubscription: EventSubscription | null = null; +let _errorSubscription: EventSubscription | null = null; +let listenersSetup = false; + +/** + * Get the storage index from MMKV + */ +function getStorageIndex(): AudioStorageIndex { + if (storageIndex) { + return storageIndex; + } + + try { + const data = storage.getString(AUDIO_STORAGE_INDEX_KEY); + if (data) { + storageIndex = JSON.parse(data) as AudioStorageIndex; + return storageIndex; + } + } catch { + // Ignore parse errors + } + + storageIndex = { + tracks: {}, + totalCacheSize: 0, + totalPermanentSize: 0, + }; + return storageIndex; +} + +/** + * Save the storage index to MMKV + */ +function saveStorageIndex(): void { + if (storageIndex) { + try { + storage.set(AUDIO_STORAGE_INDEX_KEY, JSON.stringify(storageIndex)); + } catch { + // Ignore save errors + } + } +} + +/** + * Ensure directories exist + */ +async function ensureDirectories(): Promise { + try { + if (!cacheDir) { + cacheDir = new Directory(Paths.cache, AUDIO_CACHE_DIR); + if (!cacheDir.exists) { + await cacheDir.create(); + } + } + + if (!permanentDir) { + permanentDir = new Directory(Paths.document, AUDIO_PERMANENT_DIR); + if (!permanentDir.exists) { + await permanentDir.create(); + } + } + } catch (error) { + console.warn("[AudioStorage] Failed to create directories:", error); + } +} + +/** + * Initialize audio storage - call this on app startup + */ +export async function initAudioStorage(): Promise { + console.log("[AudioStorage] Initializing..."); + try { + await ensureDirectories(); + getStorageIndex(); + setupEventListeners(); + console.log("[AudioStorage] Initialization complete"); + } catch (error) { + console.warn("[AudioStorage] Initialization error:", error); + } +} + +/** + * Set up BackgroundDownloader event listeners + * Safe to call multiple times - will only set up once + */ +function setupEventListeners(): void { + // Prevent duplicate listeners + if (listenersSetup) return; + listenersSetup = true; + + try { + console.log("[AudioStorage] Setting up event listeners..."); + + _completeSubscription = BackgroundDownloader.addCompleteListener( + (event: BGDownloadCompleteEvent) => { + console.log( + `[AudioStorage] Complete event received: taskId=${event.taskId}, activeDownloads=${JSON.stringify([...activeDownloads.entries()])}`, + ); + const downloadInfo = activeDownloads.get(event.taskId); + if (!downloadInfo) { + console.log( + `[AudioStorage] Ignoring complete event for unknown taskId: ${event.taskId}`, + ); + return; // Not an audio download + } + + handleDownloadComplete(event, downloadInfo); + }, + ); + + _errorSubscription = BackgroundDownloader.addErrorListener( + (event: BGDownloadErrorEvent) => { + console.log( + `[AudioStorage] Error event received: taskId=${event.taskId}, error=${event.error}`, + ); + const downloadInfo = activeDownloads.get(event.taskId); + if (!downloadInfo) return; // Not an audio download + + handleDownloadError(event, downloadInfo); + }, + ); + + console.log("[AudioStorage] Event listeners set up successfully"); + } catch (error) { + console.warn("[AudioStorage] Failed to setup event listeners:", error); + listenersSetup = false; + } +} + +/** + * Handle download completion + */ +async function handleDownloadComplete( + event: BGDownloadCompleteEvent, + downloadInfo: { itemId: string; permanent: boolean }, +): Promise { + const { itemId, permanent } = downloadInfo; + + try { + const file = new File(`file://${event.filePath}`); + const fileInfo = file.info(); + const size = fileInfo.size || 0; + + const index = getStorageIndex(); + + // Add to index + const trackInfo: StoredTrackInfo = { + itemId, + localPath: event.filePath, + size, + storedAt: Date.now(), + permanent, + }; + + index.tracks[itemId] = trackInfo; + + if (permanent) { + index.totalPermanentSize += size; + } else { + index.totalCacheSize += size; + } + + saveStorageIndex(); + + console.log( + `[AudioStorage] Downloaded ${itemId} (${(size / 1024 / 1024).toFixed(1)}MB, permanent=${permanent})`, + ); + + // Emit completion event + audioStorageEvents.emit("complete", { + itemId, + localPath: event.filePath, + permanent, + }); + + // Clean up tracking + activeDownloads.delete(event.taskId); + downloadingItems.delete(itemId); + permanentDownloadingItems.delete(itemId); + + // Evict old cache if needed (only for cache downloads) + if (!permanent) { + evictCacheIfNeeded().catch(() => { + // Ignore eviction errors + }); + } + } catch (error) { + console.error(`[AudioStorage] Error handling download complete:`, error); + activeDownloads.delete(event.taskId); + downloadingItems.delete(itemId); + permanentDownloadingItems.delete(itemId); + } +} + +/** + * Handle download error + */ +function handleDownloadError( + event: BGDownloadErrorEvent, + downloadInfo: { itemId: string; permanent: boolean }, +): void { + const { itemId } = downloadInfo; + + console.error(`[AudioStorage] Download failed for ${itemId}:`, event.error); + + audioStorageEvents.emit("error", { + itemId, + error: event.error, + }); + + activeDownloads.delete(event.taskId); + downloadingItems.delete(itemId); + permanentDownloadingItems.delete(itemId); +} + +/** + * Get the local file path for a track if it exists + * Checks permanent storage first, then cache + * Returns the path WITH file:// prefix for TrackPlayer + */ +export function getLocalPath(itemId: string | undefined): string | null { + if (!itemId) return null; + + try { + const index = getStorageIndex(); + const info = index.tracks[itemId]; + + if (info) { + // Verify file still exists (File constructor needs file:// URI) + try { + const fileUri = info.localPath.startsWith("file://") + ? info.localPath + : `file://${info.localPath}`; + const file = new File(fileUri); + if (file.exists) { + // Return the URI with file:// prefix for TrackPlayer + return fileUri; + } + } catch { + // File doesn't exist, remove from index + if (info.permanent) { + index.totalPermanentSize -= info.size; + } else { + index.totalCacheSize -= info.size; + } + delete index.tracks[itemId]; + saveStorageIndex(); + } + } + } catch { + // Ignore errors + } + + return null; +} + +/** + * Check if a track is currently being downloaded (any type) + */ +export function isDownloading(itemId: string | undefined): boolean { + if (!itemId) return false; + return downloadingItems.has(itemId); +} + +/** + * Check if a track is currently being permanently downloaded (user-initiated) + * Use this for UI indicators - we don't want to show spinners for auto-caching + */ +export function isPermanentDownloading(itemId: string | undefined): boolean { + if (!itemId) return false; + return permanentDownloadingItems.has(itemId); +} + +/** + * Check if a track is permanently downloaded (not just cached) + */ +export function isPermanentlyDownloaded(itemId: string | undefined): boolean { + if (!itemId) return false; + + try { + const index = getStorageIndex(); + const info = index.tracks[itemId]; + + if (info?.permanent) { + // Verify file still exists + try { + const fileUri = info.localPath.startsWith("file://") + ? info.localPath + : `file://${info.localPath}`; + const file = new File(fileUri); + if (file.exists) { + return true; + } + } catch { + // File doesn't exist + } + } + } catch { + // Ignore errors + } + + return false; +} + +/** + * Check if a track is cached (not permanently downloaded) + */ +export function isCached(itemId: string | undefined): boolean { + if (!itemId) return false; + + try { + const index = getStorageIndex(); + const info = index.tracks[itemId]; + + if (info && !info.permanent) { + // Verify file still exists + try { + const fileUri = info.localPath.startsWith("file://") + ? info.localPath + : `file://${info.localPath}`; + const file = new File(fileUri); + if (file.exists) { + return true; + } + } catch { + // File doesn't exist + } + } + } catch { + // Ignore errors + } + + return false; +} + +/** + * Download a track to storage + * @param itemId - Jellyfin item ID + * @param url - Stream URL to download from + * @param options - Download options (permanent: true for user downloads, false for cache) + */ +export async function downloadTrack( + itemId: string, + url: string, + options: DownloadOptions = { permanent: false }, +): Promise { + const { permanent } = options; + + // Skip if already downloading + if (isDownloading(itemId)) { + return; + } + + // Skip if already permanently downloaded + if (isPermanentlyDownloaded(itemId)) { + return; + } + + // If requesting permanent download and file is only cached, delete cached version first + if (permanent && isCached(itemId)) { + console.log( + `[AudioStorage] Upgrading cached track to permanent: ${itemId}`, + ); + await deleteTrack(itemId); + } + + // Skip if already cached and not requesting permanent + if (!permanent && getLocalPath(itemId)) { + return; + } + + // Ensure listeners are set up + setupEventListeners(); + + await ensureDirectories(); + + const targetDir = permanent ? permanentDir : cacheDir; + + if (!targetDir) { + console.warn("[AudioStorage] Target directory not initialized"); + return; + } + + // Use .m4a extension - compatible with iOS/Android and most audio formats + const filename = `${itemId}.m4a`; + const destinationPath = `${targetDir.uri}/${filename}`.replace("file://", ""); + + console.log( + `[AudioStorage] Starting download: ${itemId} (permanent=${permanent})`, + ); + + try { + downloadingItems.add(itemId); + if (permanent) { + permanentDownloadingItems.add(itemId); + } + const taskId = await BackgroundDownloader.startDownload( + url, + destinationPath, + ); + activeDownloads.set(taskId, { itemId, permanent }); + console.log( + `[AudioStorage] Download started with taskId=${taskId}, tracking ${activeDownloads.size} downloads`, + ); + } catch (error) { + console.error(`[AudioStorage] Failed to start download:`, error); + downloadingItems.delete(itemId); + permanentDownloadingItems.delete(itemId); + } +} + +/** + * Cancel a download in progress + */ +export function cancelDownload(itemId: string): void { + for (const [taskId, info] of activeDownloads.entries()) { + if (info.itemId === itemId) { + try { + BackgroundDownloader.cancelDownload(taskId); + } catch { + // Ignore cancel errors + } + activeDownloads.delete(taskId); + downloadingItems.delete(itemId); + permanentDownloadingItems.delete(itemId); + console.log(`[AudioStorage] Cancelled download: ${itemId}`); + break; + } + } +} + +/** + * Delete a stored track + */ +export async function deleteTrack(itemId: string): Promise { + const index = getStorageIndex(); + const info = index.tracks[itemId]; + + if (!info) return; + + try { + const file = new File(info.localPath); + if (file.exists) { + await file.delete(); + } + } catch (error) { + console.warn(`[AudioStorage] Failed to delete file:`, error); + } + + if (info.permanent) { + index.totalPermanentSize -= info.size; + } else { + index.totalCacheSize -= info.size; + } + delete index.tracks[itemId]; + saveStorageIndex(); + + console.log(`[AudioStorage] Deleted track: ${itemId}`); +} + +/** + * Evict old cache entries if limits are exceeded + */ +async function evictCacheIfNeeded( + maxTracks: number = DEFAULT_MAX_CACHE_TRACKS, + maxSizeBytes: number = DEFAULT_MAX_CACHE_SIZE_BYTES, +): Promise { + const index = getStorageIndex(); + + // Get all cache entries sorted by storedAt (oldest first) + const cacheEntries = Object.values(index.tracks) + .filter((t) => !t.permanent) + .sort((a, b) => a.storedAt - b.storedAt); + + // Evict if over track limit or size limit + while ( + cacheEntries.length > maxTracks || + index.totalCacheSize > maxSizeBytes + ) { + const oldest = cacheEntries.shift(); + if (!oldest) break; + + console.log( + `[AudioStorage] Evicting cache entry: ${oldest.itemId} (${(oldest.size / 1024 / 1024).toFixed(1)}MB)`, + ); + + try { + const file = new File(oldest.localPath); + if (file.exists) { + await file.delete(); + } + } catch { + // Ignore deletion errors + } + + index.totalCacheSize -= oldest.size; + delete index.tracks[oldest.itemId]; + } + + saveStorageIndex(); +} + +/** + * Clear all cached tracks (keeps permanent downloads) + */ +export async function clearCache(): Promise { + const index = getStorageIndex(); + + const cacheEntries = Object.values(index.tracks).filter((t) => !t.permanent); + + for (const entry of cacheEntries) { + try { + const file = new File(entry.localPath); + if (file.exists) { + await file.delete(); + } + } catch { + // Ignore deletion errors + } + delete index.tracks[entry.itemId]; + } + + index.totalCacheSize = 0; + saveStorageIndex(); + + console.log(`[AudioStorage] Cache cleared`); +} + +/** + * Clear all permanent downloads (keeps cache) + */ +export async function clearPermanentDownloads(): Promise { + const index = getStorageIndex(); + + const permanentEntries = Object.values(index.tracks).filter( + (t) => t.permanent, + ); + + for (const entry of permanentEntries) { + try { + const fileUri = entry.localPath.startsWith("file://") + ? entry.localPath + : `file://${entry.localPath}`; + const file = new File(fileUri); + if (file.exists) { + await file.delete(); + } + } catch { + // Ignore deletion errors + } + delete index.tracks[entry.itemId]; + } + + index.totalPermanentSize = 0; + saveStorageIndex(); + + console.log(`[AudioStorage] Permanent downloads cleared`); +} + +/** + * Get storage statistics + */ +export function getStorageStats(): { + cacheCount: number; + cacheSize: number; + permanentCount: number; + permanentSize: number; +} { + const index = getStorageIndex(); + const entries = Object.values(index.tracks); + + return { + cacheCount: entries.filter((t) => !t.permanent).length, + cacheSize: index.totalCacheSize, + permanentCount: entries.filter((t) => t.permanent).length, + permanentSize: index.totalPermanentSize, + }; +} diff --git a/providers/AudioStorage/types.ts b/providers/AudioStorage/types.ts new file mode 100644 index 00000000..c93f7f0a --- /dev/null +++ b/providers/AudioStorage/types.ts @@ -0,0 +1,41 @@ +/** + * Audio Storage Types + * + * Shared foundation supporting both: + * - Look-ahead cache (auto-managed, ephemeral) + * - Future full music downloads (user-initiated, permanent) + */ + +export interface StoredTrackInfo { + itemId: string; + localPath: string; + size: number; + storedAt: number; + permanent: boolean; // true = user download, false = cache +} + +export interface AudioStorageIndex { + tracks: Record; + totalCacheSize: number; + totalPermanentSize: number; +} + +export interface DownloadOptions { + permanent: boolean; +} + +export interface DownloadCompleteEvent { + itemId: string; + localPath: string; + permanent: boolean; +} + +export interface DownloadErrorEvent { + itemId: string; + error: string; +} + +export interface DownloadProgressEvent { + itemId: string; + progress: number; // 0-1 +} diff --git a/providers/MusicPlayerProvider.tsx b/providers/MusicPlayerProvider.tsx index bdfe7a8b..5a134e5a 100644 --- a/providers/MusicPlayerProvider.tsx +++ b/providers/MusicPlayerProvider.tsx @@ -3,7 +3,7 @@ import type { BaseItemDto, MediaSourceInfo, } from "@jellyfin/sdk/lib/generated-client/models"; -import { getMediaInfoApi, getPlaystateApi } from "@jellyfin/sdk/lib/utils/api"; +import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api"; import { useAtomValue } from "jotai"; import React, { createContext, @@ -21,9 +21,16 @@ import TrackPlayer, { RepeatMode as TPRepeatMode, type Track, } from "react-native-track-player"; +import { + downloadTrack, + getLocalPath, + initAudioStorage, + isDownloading, +} from "@/providers/AudioStorage"; import { apiAtom, userAtom } from "@/providers/JellyfinProvider"; +import { settingsAtom } from "@/utils/atoms/settings"; +import { getAudioStreamUrl } from "@/utils/jellyfin/audio/getAudioStreamUrl"; import { storage } from "@/utils/mmkv"; -import native from "@/utils/profiles/native"; // Storage keys const STORAGE_KEYS = { @@ -93,6 +100,9 @@ interface MusicPlayerContextType extends MusicPlayerState { reportProgress: () => void; onTrackEnd: () => void; syncFromTrackPlayer: () => void; + + // Audio caching + triggerLookahead: () => void; } const MusicPlayerContext = createContext( @@ -202,75 +212,33 @@ const shuffleArray = (array: T[], currentIndex: number): T[] => { return result; }; -const getAudioStreamUrl = async ( - api: Api, - userId: string, - itemId: string, -): Promise<{ - url: string; - sessionId: string | null; - mediaSource: MediaSourceInfo | null; - isTranscoding: boolean; -} | null> => { - try { - const res = await getMediaInfoApi(api).getPlaybackInfo( - { itemId }, - { - method: "POST", - data: { - userId, - deviceProfile: native, - startTimeTicks: 0, - isPlayback: true, - autoOpenLiveStream: true, - }, - }, - ); - - const sessionId = res.data.PlaySessionId || null; - const mediaSource = res.data.MediaSources?.[0] || null; - - if (mediaSource?.TranscodingUrl) { - return { - url: `${api.basePath}${mediaSource.TranscodingUrl}`, - sessionId, - mediaSource, - isTranscoding: true, - }; - } - - // Direct stream - const streamParams = new URLSearchParams({ - static: "true", - container: mediaSource?.Container || "mp3", - mediaSourceId: mediaSource?.Id || "", - deviceId: api.deviceInfo.id, - api_key: api.accessToken, - userId, - }); - - return { - url: `${api.basePath}/Audio/${itemId}/stream?${streamParams.toString()}`, - sessionId, - mediaSource, - isTranscoding: false, - }; - } catch { - return null; - } -}; - // Convert BaseItemDto to TrackPlayer Track -const itemToTrack = (item: BaseItemDto, url: string, api: Api): Track => { +const itemToTrack = ( + item: BaseItemDto, + url: string, + api: Api, + preferLocalAudio = true, +): Track => { const albumId = item.AlbumId || item.ParentId; const artworkId = albumId || item.Id; const artwork = artworkId ? `${api.basePath}/Items/${artworkId}/Images/Primary?maxHeight=512&maxWidth=512&quality=90` : undefined; + // Check if track is cached locally (permanent downloads take precedence) + // getLocalPath returns full file:// URI if cached, null otherwise + const cachedUrl = preferLocalAudio ? getLocalPath(item.Id) : null; + const finalUrl = cachedUrl || url; + + if (cachedUrl) { + console.log( + `[MusicPlayer] Using cached file for ${item.Name}: ${cachedUrl}`, + ); + } + return { id: item.Id || "", - url, + url: finalUrl, title: item.Name || "Unknown", artist: item.Artists?.join(", ") || item.AlbumArtist || "Unknown Artist", album: item.Album || undefined, @@ -284,6 +252,7 @@ export const MusicPlayerProvider: React.FC = ({ }) => { const api = useAtomValue(apiAtom); const user = useAtomValue(userAtom); + const settings = useAtomValue(settingsAtom); const initializedRef = useRef(false); const playerSetupRef = useRef(false); @@ -308,12 +277,15 @@ export const MusicPlayerProvider: React.FC = ({ const lastReportRef = useRef(0); - // Setup TrackPlayer + // Setup TrackPlayer and AudioStorage useEffect(() => { const setupPlayer = async () => { if (playerSetupRef.current) return; try { + // Initialize audio storage for caching + await initAudioStorage(); + await TrackPlayer.setupPlayer(); await TrackPlayer.updateOptions({ capabilities: [ @@ -498,12 +470,20 @@ export const MusicPlayerProvider: React.FC = ({ let startTrackMediaSource: MediaSourceInfo | null = null; let startTrackIsTranscoding = false; + const preferLocal = settings?.preferLocalAudio ?? true; + for (let i = 0; i < queue.length; i++) { const item = queue[i]; if (!item.Id) continue; + + // First check for cached version (for offline fallback) + const cachedUrl = getLocalPath(item.Id); + + // Try to get stream URL from server const result = await getAudioStreamUrl(api, user.Id, item.Id); + if (result) { - tracks.push(itemToTrack(item, result.url, api)); + tracks.push(itemToTrack(item, result.url, api, preferLocal)); // Store media info for all tracks mediaInfoMap[item.Id] = { mediaSource: result.mediaSource, @@ -521,6 +501,12 @@ export const MusicPlayerProvider: React.FC = ({ startTrackMediaSource = result.mediaSource; startTrackIsTranscoding = result.isTranscoding; } + } else if (cachedUrl) { + // Fallback to cached version if server is unreachable + console.log( + `[MusicPlayer] Using cached file (offline) for ${item.Name}: ${cachedUrl}`, + ); + tracks.push(itemToTrack(item, cachedUrl, api, true)); } } @@ -688,8 +674,11 @@ export const MusicPlayerProvider: React.FC = ({ state.currentTrack.Id!, ); if (result) { + const preferLocal = settings?.preferLocalAudio ?? true; await TrackPlayer.reset(); - await TrackPlayer.add(itemToTrack(state.currentTrack, result.url, api)); + await TrackPlayer.add( + itemToTrack(state.currentTrack, result.url, api, preferLocal), + ); await TrackPlayer.seekTo(state.progress); await TrackPlayer.play(); setState((prev) => ({ @@ -703,7 +692,14 @@ export const MusicPlayerProvider: React.FC = ({ await TrackPlayer.play(); setState((prev) => ({ ...prev, isPlaying: true })); } - }, [api, user?.Id, state.streamUrl, state.currentTrack, state.progress]); + }, [ + api, + user?.Id, + state.streamUrl, + state.currentTrack, + state.progress, + settings?.preferLocalAudio, + ]); const togglePlayPause = useCallback(async () => { if (state.isPlaying) { @@ -899,13 +895,22 @@ export const MusicPlayerProvider: React.FC = ({ if (!api || !user?.Id) return; const tracksArray = Array.isArray(tracks) ? tracks : [tracks]; + const preferLocal = settings?.preferLocalAudio ?? true; // Add to TrackPlayer queue for (const item of tracksArray) { if (!item.Id) continue; + const cachedUrl = getLocalPath(item.Id); const result = await getAudioStreamUrl(api, user.Id, item.Id); if (result) { - await TrackPlayer.add(itemToTrack(item, result.url, api)); + await TrackPlayer.add( + itemToTrack(item, result.url, api, preferLocal), + ); + } else if (cachedUrl) { + console.log( + `[MusicPlayer] Using cached file (offline) for ${item.Name}: ${cachedUrl}`, + ); + await TrackPlayer.add(itemToTrack(item, cachedUrl, api, true)); } } @@ -915,7 +920,7 @@ export const MusicPlayerProvider: React.FC = ({ originalQueue: [...prev.originalQueue, ...tracksArray], })); }, - [api, user?.Id], + [api, user?.Id, settings?.preferLocalAudio], ); const playNext = useCallback( @@ -925,15 +930,25 @@ export const MusicPlayerProvider: React.FC = ({ const tracksArray = Array.isArray(tracks) ? tracks : [tracks]; const currentIndex = await TrackPlayer.getActiveTrackIndex(); const insertIndex = (currentIndex ?? -1) + 1; + const preferLocal = settings?.preferLocalAudio ?? true; // Add to TrackPlayer queue after current track for (let i = tracksArray.length - 1; i >= 0; i--) { const item = tracksArray[i]; if (!item.Id) continue; + const cachedUrl = getLocalPath(item.Id); const result = await getAudioStreamUrl(api, user.Id, item.Id); if (result) { await TrackPlayer.add( - itemToTrack(item, result.url, api), + itemToTrack(item, result.url, api, preferLocal), + insertIndex, + ); + } else if (cachedUrl) { + console.log( + `[MusicPlayer] Using cached file (offline) for ${item.Name}: ${cachedUrl}`, + ); + await TrackPlayer.add( + itemToTrack(item, cachedUrl, api, true), insertIndex, ); } @@ -954,7 +969,7 @@ export const MusicPlayerProvider: React.FC = ({ }; }); }, - [api, user?.Id], + [api, user?.Id, settings?.preferLocalAudio], ); const removeFromQueue = useCallback(async (index: number) => { @@ -1166,6 +1181,49 @@ export const MusicPlayerProvider: React.FC = ({ // For other modes, TrackPlayer handles it via repeat mode setting }, [state.repeatMode]); + // Cache current track + look-ahead: pre-cache current and next N tracks + const triggerLookahead = useCallback(async () => { + // Check if caching is enabled in settings + if (settings?.audioLookaheadEnabled === false) return; + if (!api || !user?.Id) return; + + try { + const tpQueue = await TrackPlayer.getQueue(); + const currentIdx = await TrackPlayer.getActiveTrackIndex(); + if (currentIdx === undefined || currentIdx < 0) return; + + // Cache current track + next N tracks (from settings, default 2) + const lookaheadCount = settings?.audioLookaheadCount ?? 2; + const tracksToCache = tpQueue.slice( + currentIdx, + currentIdx + 1 + lookaheadCount, + ); + + for (const track of tracksToCache) { + const itemId = track.id; + // Skip if already stored locally or currently downloading + if (!itemId || getLocalPath(itemId) || isDownloading(itemId)) continue; + + // Get stream URL for this track + const result = await getAudioStreamUrl(api, user.Id, itemId); + + // Only cache direct streams (not transcoding - can't cache dynamic content) + if (result?.url && !result.isTranscoding) { + downloadTrack(itemId, result.url, { permanent: false }).catch(() => { + // Silent fail - caching is best-effort + }); + } + } + } catch { + // Silent fail - look-ahead caching is best-effort + } + }, [ + api, + user?.Id, + settings?.audioLookaheadEnabled, + settings?.audioLookaheadCount, + ]); + const value = useMemo( () => ({ ...state, @@ -1194,6 +1252,7 @@ export const MusicPlayerProvider: React.FC = ({ reportProgress: reportPlaybackProgress, onTrackEnd, syncFromTrackPlayer, + triggerLookahead, }), [ state, @@ -1222,6 +1281,7 @@ export const MusicPlayerProvider: React.FC = ({ reportPlaybackProgress, onTrackEnd, syncFromTrackPlayer, + triggerLookahead, ], ); diff --git a/providers/NetworkStatusProvider.tsx b/providers/NetworkStatusProvider.tsx index d44caac6..b9bea264 100644 --- a/providers/NetworkStatusProvider.tsx +++ b/providers/NetworkStatusProvider.tsx @@ -24,7 +24,8 @@ const NetworkStatusContext = createContext( async function checkApiReachable(basePath?: string): Promise { if (!basePath) return false; try { - const response = await fetch(basePath, { method: "HEAD" }); + const url = basePath.endsWith("/") ? basePath : `${basePath}/`; + const response = await fetch(url, { method: "HEAD" }); return response.ok; } catch { return false; diff --git a/translations/en.json b/translations/en.json index 0a563703..da3d5a7f 100644 --- a/translations/en.json +++ b/translations/en.json @@ -225,6 +225,15 @@ "downloads": { "downloads_title": "Downloads" }, + "music": { + "title": "Music", + "playback_title": "Playback", + "playback_description": "Configure how music is played.", + "prefer_downloaded": "Prefer Downloaded Songs", + "caching_title": "Caching", + "caching_description": "Automatically cache upcoming tracks for smoother playback.", + "lookahead_enabled": "Enable Look-Ahead Caching" + }, "plugins": { "plugins_title": "Plugins", "jellyseerr": { @@ -297,7 +306,16 @@ "app_usage": "App {{usedSpace}}%", "device_usage": "Device {{availableSpace}}%", "size_used": "{{used}} of {{total}} Used", - "delete_all_downloaded_files": "Delete All Downloaded Files" + "delete_all_downloaded_files": "Delete All Downloaded Files", + "music_cache_title": "Music Cache", + "music_cache_description": "Automatically cache songs as you listen for smoother playback and offline support", + "enable_music_cache": "Enable Music Cache", + "clear_music_cache": "Clear Music Cache", + "music_cache_size": "{{size}} cached", + "music_cache_cleared": "Music cache cleared", + "delete_all_downloaded_songs": "Delete All Downloaded Songs", + "downloaded_songs_size": "{{size}} downloaded", + "downloaded_songs_deleted": "Downloaded songs deleted" }, "intro": { "title": "Intro", @@ -627,7 +645,15 @@ "track_options": { "play_next": "Play Next", "add_to_queue": "Add to Queue", - "add_to_playlist": "Add to Playlist" + "add_to_playlist": "Add to Playlist", + "download": "Download", + "downloaded": "Downloaded", + "downloading": "Downloading...", + "cached": "Cached", + "go_to_artist": "Go to Artist", + "go_to_album": "Go to Album", + "add_to_favorites": "Add to Favorites", + "remove_from_favorites": "Remove from Favorites" }, "playlists": { "create_playlist": "Create Playlist", diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts index 6a7f58f7..f5470f60 100644 --- a/utils/atoms/settings.ts +++ b/utils/atoms/settings.ts @@ -202,6 +202,12 @@ export type Settings = { videoPlayerIOS: VideoPlayerIOS; // Appearance hideRemoteSessionButton: boolean; + // Audio look-ahead caching + audioLookaheadEnabled: boolean; + audioLookaheadCount: number; + audioMaxCacheSizeMB: number; + // Music playback + preferLocalAudio: boolean; }; export interface Lockable { @@ -284,6 +290,12 @@ export const defaultValues: Settings = { videoPlayerIOS: VideoPlayerIOS.VLC, // Appearance hideRemoteSessionButton: false, + // Audio look-ahead caching defaults + audioLookaheadEnabled: true, + audioLookaheadCount: 2, + audioMaxCacheSizeMB: 100, + // Music playback + preferLocalAudio: true, }; const loadSettings = (): Partial => { diff --git a/utils/jellyfin/audio/getAudioStreamUrl.ts b/utils/jellyfin/audio/getAudioStreamUrl.ts new file mode 100644 index 00000000..f330a4fc --- /dev/null +++ b/utils/jellyfin/audio/getAudioStreamUrl.ts @@ -0,0 +1,68 @@ +import type { Api } from "@jellyfin/sdk"; +import type { MediaSourceInfo } from "@jellyfin/sdk/lib/generated-client/models"; +import { getMediaInfoApi } from "@jellyfin/sdk/lib/utils/api"; +import native from "@/utils/profiles/native"; + +export interface AudioStreamResult { + url: string; + sessionId: string | null; + mediaSource: MediaSourceInfo | null; + isTranscoding: boolean; +} + +/** + * Get the audio stream URL for a Jellyfin item + * Handles both direct streaming and transcoding scenarios + */ +export const getAudioStreamUrl = async ( + api: Api, + userId: string, + itemId: string, +): Promise => { + try { + const res = await getMediaInfoApi(api).getPlaybackInfo( + { itemId }, + { + method: "POST", + data: { + userId, + deviceProfile: native, + startTimeTicks: 0, + isPlayback: true, + autoOpenLiveStream: true, + }, + }, + ); + + const sessionId = res.data.PlaySessionId || null; + const mediaSource = res.data.MediaSources?.[0] || null; + + if (mediaSource?.TranscodingUrl) { + return { + url: `${api.basePath}${mediaSource.TranscodingUrl}`, + sessionId, + mediaSource, + isTranscoding: true, + }; + } + + // Direct stream + const streamParams = new URLSearchParams({ + static: "true", + container: mediaSource?.Container || "mp3", + mediaSourceId: mediaSource?.Id || "", + deviceId: api.deviceInfo.id, + api_key: api.accessToken, + userId, + }); + + return { + url: `${api.basePath}/Audio/${itemId}/stream?${streamParams.toString()}`, + sessionId, + mediaSource, + isTranscoding: false, + }; + } catch { + return null; + } +};