feat: cache and download music

This commit is contained in:
Fredrik Burmester
2026-01-04 12:50:41 +01:00
parent b1da9f8777
commit ab3465aec5
22 changed files with 1616 additions and 110 deletions

View File

@@ -166,6 +166,24 @@ export default function IndexLayout() {
),
}}
/>
<Stack.Screen
name='settings/music/page'
options={{
title: t("home.settings.music.title"),
headerBlurEffect: "none",
headerTransparent: Platform.OS === "ios",
headerShadowVisible: false,
headerLeft: () => (
<TouchableOpacity
onPress={() => _router.back()}
className='pl-0.5'
style={{ marginRight: Platform.OS === "android" ? 16 : 0 }}
>
<Feather name='chevron-left' size={28} color='white' />
</TouchableOpacity>
),
}}
/>
<Stack.Screen
name='settings/appearance/hide-libraries/page'
options={{

View File

@@ -70,6 +70,11 @@ export default function settings() {
showArrow
title={t("home.settings.audio_subtitles.title")}
/>
<ListItem
onPress={() => router.push("/settings/music/page")}
showArrow
title={t("home.settings.music.title")}
/>
<ListItem
onPress={() => router.push("/settings/appearance/page")}
showArrow

View File

@@ -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 (
<ScrollView
contentInsetAdjustmentBehavior='automatic'
contentContainerStyle={{
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
<View
className='p-4 flex flex-col'
style={{ paddingTop: Platform.OS === "android" ? 10 : 0 }}
>
<ListGroup
title={t("home.settings.music.playback_title")}
description={
<Text className='text-[#8E8D91] text-xs'>
{t("home.settings.music.playback_description")}
</Text>
}
>
<ListItem
title={t("home.settings.music.prefer_downloaded")}
disabled={pluginSettings?.preferLocalAudio?.locked}
>
<Switch
value={settings.preferLocalAudio}
disabled={pluginSettings?.preferLocalAudio?.locked}
onValueChange={(value) =>
updateSettings({ preferLocalAudio: value })
}
/>
</ListItem>
</ListGroup>
<View className='mt-4'>
<ListGroup
title={t("home.settings.music.caching_title")}
description={
<Text className='text-[#8E8D91] text-xs'>
{t("home.settings.music.caching_description")}
</Text>
}
>
<ListItem
title={t("home.settings.music.lookahead_enabled")}
disabled={pluginSettings?.audioLookaheadEnabled?.locked}
>
<Switch
value={settings.audioLookaheadEnabled}
disabled={pluginSettings?.audioLookaheadEnabled?.locked}
onValueChange={(value) =>
updateSettings({ audioLookaheadEnabled: value })
}
/>
</ListItem>
</ListGroup>
</View>
</View>
</ScrollView>
);
}

View File

@@ -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() {
</Text>
{/* Play buttons */}
<View className='flex flex-row mt-4'>
<View className='flex flex-row mt-4 items-center'>
<TouchableOpacity
onPress={handlePlayAll}
className='flex flex-row items-center bg-purple-600 px-6 py-3 rounded-full mr-3'
@@ -196,13 +231,32 @@ export default function AlbumDetailScreen() {
</TouchableOpacity>
<TouchableOpacity
onPress={handleShuffle}
className='flex flex-row items-center bg-neutral-800 px-6 py-3 rounded-full'
className='flex flex-row items-center bg-neutral-800 px-6 py-3 rounded-full mr-3'
>
<Ionicons name='shuffle' size={20} color='white' />
<Text className='text-white font-medium ml-2'>
{t("music.shuffle")}
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={handleDownloadAlbum}
disabled={allTracksDownloaded || isDownloading}
className='flex items-center justify-center bg-neutral-800 p-3 rounded-full'
>
{isDownloading ? (
<ActivityIndicator size={20} color='white' />
) : (
<Ionicons
name={
allTracksDownloaded
? "checkmark-circle"
: "download-outline"
}
size={20}
color={allTracksDownloaded ? "#22c55e" : "white"}
/>
)}
</TouchableOpacity>
</View>
</View>
}

View File

@@ -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() {
</Text>
{/* Play buttons */}
<View className='flex flex-row mt-4'>
<View className='flex flex-row mt-4 items-center'>
<TouchableOpacity
onPress={handlePlayAll}
className='flex flex-row items-center bg-purple-600 px-6 py-3 rounded-full mr-3'
@@ -192,13 +224,32 @@ export default function PlaylistDetailScreen() {
</TouchableOpacity>
<TouchableOpacity
onPress={handleShuffle}
className='flex flex-row items-center bg-neutral-800 px-6 py-3 rounded-full'
className='flex flex-row items-center bg-neutral-800 px-6 py-3 rounded-full mr-3'
>
<Ionicons name='shuffle' size={20} color='white' />
<Text className='text-white font-medium ml-2'>
{t("music.shuffle")}
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={handleDownloadPlaylist}
disabled={allTracksDownloaded || isDownloading}
className='flex items-center justify-center bg-neutral-800 p-3 rounded-full'
>
{isDownloading ? (
<ActivityIndicator size={20} color='white' />
) : (
<Ionicons
name={
allTracksDownloaded
? "checkmark-circle"
: "download-outline"
}
size={20}
color={allTracksDownloaded ? "#22c55e" : "white"}
/>
)}
</TouchableOpacity>
</View>
</View>
}

View File

@@ -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"],

View File

@@ -411,7 +411,7 @@ const PlayerView: React.FC<PlayerViewProps> = ({
</View>
{/* Main Controls */}
<View className='flex flex-row items-center justify-center mb-4'>
<View className='flex flex-row items-center justify-center mb-2'>
<TouchableOpacity
onPress={onPrevious}
disabled={!canGoPrevious || isLoading}
@@ -449,7 +449,7 @@ const PlayerView: React.FC<PlayerViewProps> = ({
</View>
{/* Shuffle & Repeat Controls */}
<View className='flex flex-row items-center justify-center mb-6'>
<View className='flex flex-row items-center justify-center mb-2'>
<TouchableOpacity onPress={onToggleShuffle} className='p-3 mx-4'>
<Ionicons
name='shuffle'
@@ -465,7 +465,7 @@ const PlayerView: React.FC<PlayerViewProps> = ({
color={repeatMode !== "off" ? "#9334E9" : "#666"}
/>
{repeatMode === "one" && (
<View className='absolute -top-1 -right-1 bg-purple-600 rounded-full w-4 h-4 items-center justify-center'>
<View className='absolute right-0 bg-purple-600 rounded-full w-4 h-4 items-center justify-center'>
<Text className='text-white text-[10px] font-bold'>1</Text>
</View>
)}
@@ -474,7 +474,7 @@ const PlayerView: React.FC<PlayerViewProps> = ({
{/* Queue info */}
{queue.length > 1 && (
<View className='items-center mb-8'>
<View className='items-center mb-4'>
<Text className='text-neutral-500 text-sm'>
{queueIndex + 1} of {queue.length}
</Text>

View File

@@ -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 (
<QueryClientProvider client={queryClient}>
<PersistQueryClientProvider
client={queryClient}
persistOptions={{
persister: mmkvPersister,
maxAge: 1000 * 60 * 60 * 24, // 24 hours max cache age
dehydrateOptions: {
shouldDehydrateQuery: (query) => {
// Only persist successful queries
return query.state.status === "success";
},
},
}}
>
<JellyfinProvider>
<NetworkStatusProvider>
<PlaySettingsProvider>
@@ -410,6 +434,6 @@ function Layout() {
</PlaySettingsProvider>
</NetworkStatusProvider>
</JellyfinProvider>
</QueryClientProvider>
</PersistQueryClientProvider>
);
}

View File

@@ -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=="],

View File

@@ -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;

View File

@@ -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}

View File

@@ -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>
);

View File

@@ -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>
);

View File

@@ -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"));
},

View File

@@ -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",

View File

@@ -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<string>();
// Track permanent downloads separately for UI indicator
const permanentDownloadingItems = new Set<string>();
// 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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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,
};
}

View File

@@ -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<string, StoredTrackInfo>;
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
}

View File

@@ -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<MusicPlayerContextType | undefined>(
@@ -202,75 +212,33 @@ const shuffleArray = <T,>(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<MusicPlayerProviderProps> = ({
}) => {
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<MusicPlayerProviderProps> = ({
const lastReportRef = useRef<number>(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<MusicPlayerProviderProps> = ({
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<MusicPlayerProviderProps> = ({
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<MusicPlayerProviderProps> = ({
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<MusicPlayerProviderProps> = ({
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<MusicPlayerProviderProps> = ({
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<MusicPlayerProviderProps> = ({
originalQueue: [...prev.originalQueue, ...tracksArray],
}));
},
[api, user?.Id],
[api, user?.Id, settings?.preferLocalAudio],
);
const playNext = useCallback(
@@ -925,15 +930,25 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
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<MusicPlayerProviderProps> = ({
};
});
},
[api, user?.Id],
[api, user?.Id, settings?.preferLocalAudio],
);
const removeFromQueue = useCallback(async (index: number) => {
@@ -1166,6 +1181,49 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
// 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<MusicPlayerProviderProps> = ({
reportProgress: reportPlaybackProgress,
onTrackEnd,
syncFromTrackPlayer,
triggerLookahead,
}),
[
state,
@@ -1222,6 +1281,7 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
reportPlaybackProgress,
onTrackEnd,
syncFromTrackPlayer,
triggerLookahead,
],
);

View File

@@ -24,7 +24,8 @@ const NetworkStatusContext = createContext<NetworkStatusContextType | null>(
async function checkApiReachable(basePath?: string): Promise<boolean> {
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;

View File

@@ -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",

View File

@@ -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<T> {
@@ -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<Settings> => {

View File

@@ -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<AudioStreamResult | 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;
}
};