diff --git a/app/(auth)/(tabs)/(home)/settings/music/page.tsx b/app/(auth)/(tabs)/(home)/settings/music/page.tsx
index bede0677..1a89cd18 100644
--- a/app/(auth)/(tabs)/(home)/settings/music/page.tsx
+++ b/app/(auth)/(tabs)/(home)/settings/music/page.tsx
@@ -1,3 +1,5 @@
+import { Ionicons } from "@expo/vector-icons";
+import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Platform, ScrollView, View } from "react-native";
import { Switch } from "react-native-gesture-handler";
@@ -5,13 +7,68 @@ 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 { PlatformDropdown } from "@/components/PlatformDropdown";
import { useSettings } from "@/utils/atoms/settings";
+const CACHE_SIZE_OPTIONS = [
+ { label: "100 MB", value: 100 },
+ { label: "250 MB", value: 250 },
+ { label: "500 MB", value: 500 },
+ { label: "1 GB", value: 1024 },
+ { label: "2 GB", value: 2048 },
+];
+
+const LOOKAHEAD_COUNT_OPTIONS = [
+ { label: "1 song", value: 1 },
+ { label: "2 songs", value: 2 },
+ { label: "3 songs", value: 3 },
+ { label: "5 songs", value: 5 },
+];
+
export default function MusicSettingsPage() {
const insets = useSafeAreaInsets();
const { settings, updateSettings, pluginSettings } = useSettings();
const { t } = useTranslation();
+ const cacheSizeOptions = useMemo(
+ () => [
+ {
+ options: CACHE_SIZE_OPTIONS.map((option) => ({
+ type: "radio" as const,
+ label: option.label,
+ value: String(option.value),
+ selected: option.value === settings?.audioMaxCacheSizeMB,
+ onPress: () => updateSettings({ audioMaxCacheSizeMB: option.value }),
+ })),
+ },
+ ],
+ [settings?.audioMaxCacheSizeMB, updateSettings],
+ );
+
+ const currentCacheSizeLabel =
+ CACHE_SIZE_OPTIONS.find((o) => o.value === settings?.audioMaxCacheSizeMB)
+ ?.label ?? `${settings?.audioMaxCacheSizeMB} MB`;
+
+ const lookaheadCountOptions = useMemo(
+ () => [
+ {
+ options: LOOKAHEAD_COUNT_OPTIONS.map((option) => ({
+ type: "radio" as const,
+ label: option.label,
+ value: String(option.value),
+ selected: option.value === settings?.audioLookaheadCount,
+ onPress: () => updateSettings({ audioLookaheadCount: option.value }),
+ })),
+ },
+ ],
+ [settings?.audioLookaheadCount, updateSettings],
+ );
+
+ const currentLookaheadLabel =
+ LOOKAHEAD_COUNT_OPTIONS.find(
+ (o) => o.value === settings?.audioLookaheadCount,
+ )?.label ?? `${settings?.audioLookaheadCount} songs`;
+
return (
+
+
+
+ {currentLookaheadLabel}
+
+
+
+ }
+ title={t("home.settings.music.lookahead_count")}
+ />
+
+
+
+
+ {currentCacheSizeLabel}
+
+
+
+ }
+ title={t("home.settings.music.max_cache_size")}
+ />
+
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 3e4bbf44..1d1b7620 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
@@ -139,7 +139,10 @@ export default function AlbumDetailScreen() {
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 });
+ await downloadTrack(track.Id, result.url, {
+ permanent: true,
+ container: result.mediaSource?.Container || undefined,
+ });
}
}
} catch {
@@ -150,7 +153,8 @@ export default function AlbumDetailScreen() {
const isLoading = loadingAlbum || loadingTracks;
- if (isLoading) {
+ // Only show loading if we have no cached data to display
+ if (isLoading && !album) {
return (
diff --git a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/music/artist/[artistId].tsx b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/music/artist/[artistId].tsx
index 5ef4b71f..8fb54202 100644
--- a/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/music/artist/[artistId].tsx
+++ b/app/(auth)/(tabs)/(home,libraries,search,favorites,watchlists)/music/artist/[artistId].tsx
@@ -120,7 +120,8 @@ export default function ArtistDetailScreen() {
const isLoading = loadingArtist || loadingAlbums || loadingTracks;
- if (isLoading) {
+ // Only show loading if we have no cached data to display
+ if (isLoading && !artist) {
return (
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 4a8dabd4..80a6dd8a 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
@@ -146,7 +146,10 @@ export default function PlaylistDetailScreen() {
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 });
+ await downloadTrack(track.Id, result.url, {
+ permanent: true,
+ container: result.mediaSource?.Container || undefined,
+ });
}
}
} catch {
@@ -157,7 +160,8 @@ export default function PlaylistDetailScreen() {
const isLoading = loadingPlaylist || loadingTracks;
- if (isLoading) {
+ // Only show loading if we have no cached data to display
+ if (isLoading && !playlist) {
return (
diff --git a/app/(auth)/(tabs)/(libraries)/music/[libraryId]/artists.tsx b/app/(auth)/(tabs)/(libraries)/music/[libraryId]/artists.tsx
index 38bddc57..9afa2bbb 100644
--- a/app/(auth)/(tabs)/(libraries)/music/[libraryId]/artists.tsx
+++ b/app/(auth)/(tabs)/(libraries)/music/[libraryId]/artists.tsx
@@ -102,7 +102,8 @@ export default function ArtistsScreen() {
);
}
- if (isLoading) {
+ // Only show loading if we have no cached data to display
+ if (isLoading && artists.length === 0) {
return (
@@ -110,7 +111,9 @@ export default function ArtistsScreen() {
);
}
- if (isError) {
+ // Only show error if we have no cached data to display
+ // This allows offline access to previously cached artists
+ if (isError && artists.length === 0) {
return (
diff --git a/app/(auth)/(tabs)/(libraries)/music/[libraryId]/playlists.tsx b/app/(auth)/(tabs)/(libraries)/music/[libraryId]/playlists.tsx
index 2afdd116..3e8418d2 100644
--- a/app/(auth)/(tabs)/(libraries)/music/[libraryId]/playlists.tsx
+++ b/app/(auth)/(tabs)/(libraries)/music/[libraryId]/playlists.tsx
@@ -124,7 +124,8 @@ export default function PlaylistsScreen() {
);
}
- if (isLoading) {
+ // Only show loading if we have no cached data to display
+ if (isLoading && playlists.length === 0) {
return (
@@ -132,7 +133,9 @@ export default function PlaylistsScreen() {
);
}
- if (isError) {
+ // Only show error if we have no cached data to display
+ // This allows offline access to previously cached playlists
+ if (isError && playlists.length === 0) {
return (
diff --git a/app/(auth)/(tabs)/(libraries)/music/[libraryId]/suggestions.tsx b/app/(auth)/(tabs)/(libraries)/music/[libraryId]/suggestions.tsx
index 495c8a39..21323a60 100644
--- a/app/(auth)/(tabs)/(libraries)/music/[libraryId]/suggestions.tsx
+++ b/app/(auth)/(tabs)/(libraries)/music/[libraryId]/suggestions.tsx
@@ -232,7 +232,8 @@ export default function SuggestionsScreen() {
);
}
- if (isLoading) {
+ // Only show loading if we have no cached data to display
+ if (isLoading && sections.length === 0) {
return (
@@ -240,7 +241,12 @@ export default function SuggestionsScreen() {
);
}
- if (isLatestError || isRecentlyPlayedError || isFrequentError) {
+ // Only show error if we have no cached data to display
+ // This allows offline access to previously cached suggestions
+ if (
+ (isLatestError || isRecentlyPlayedError || isFrequentError) &&
+ sections.length === 0
+ ) {
const msg =
(latestError as Error | undefined)?.message ||
(recentlyPlayedError as Error | undefined)?.message ||
diff --git a/app/(auth)/now-playing.tsx b/app/(auth)/now-playing.tsx
index be1df280..e2f7d473 100644
--- a/app/(auth)/now-playing.tsx
+++ b/app/(auth)/now-playing.tsx
@@ -10,13 +10,16 @@ import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
ActivityIndicator,
Dimensions,
- FlatList,
Platform,
ScrollView,
TouchableOpacity,
View,
} from "react-native";
import { Slider } from "react-native-awesome-slider";
+import DraggableFlatList, {
+ type RenderItemParams,
+ ScaleDecorator,
+} from "react-native-draggable-flatlist";
import { useSharedValue } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Badge } from "@/components/Badge";
@@ -73,6 +76,7 @@ export default function NowPlayingScreen() {
toggleShuffle,
jumpToIndex,
removeFromQueue,
+ reorderQueue,
stop,
} = useMusicPlayer();
@@ -244,6 +248,7 @@ export default function NowPlayingScreen() {
queueIndex={queueIndex}
onJumpToIndex={jumpToIndex}
onRemoveFromQueue={removeFromQueue}
+ onReorderQueue={reorderQueue}
/>
)}
@@ -490,6 +495,7 @@ interface QueueViewProps {
queueIndex: number;
onJumpToIndex: (index: number) => void;
onRemoveFromQueue: (index: number) => void;
+ onReorderQueue: (newQueue: BaseItemDto[]) => void;
}
const QueueView: React.FC = ({
@@ -498,9 +504,11 @@ const QueueView: React.FC = ({
queueIndex,
onJumpToIndex,
onRemoveFromQueue,
+ onReorderQueue,
}) => {
const renderQueueItem = useCallback(
- ({ item, index }: { item: BaseItemDto; index: number }) => {
+ ({ item, drag, isActive, getIndex }: RenderItemParams) => {
+ const index = getIndex() ?? 0;
const isCurrentTrack = index === queueIndex;
const isPast = index < queueIndex;
@@ -512,80 +520,102 @@ const QueueView: React.FC = ({
: null;
return (
- onJumpToIndex(index)}
- className={`flex-row items-center px-4 py-3 ${isCurrentTrack ? "bg-purple-900/30" : ""}`}
- style={{ opacity: isPast ? 0.5 : 1 }}
- >
- {/* Track number / Now playing indicator */}
-
- {isCurrentTrack ? (
-
- ) : (
- {index + 1}
- )}
-
-
- {/* Album art */}
-
- {imageUrl ? (
-
- ) : (
-
-
-
- )}
-
-
- {/* Track info */}
-
-
- {item.Name}
-
-
- {item.Artists?.join(", ") || item.AlbumArtist}
-
-
-
- {/* Remove button (not for current track) */}
- {!isCurrentTrack && (
+
+ onJumpToIndex(index)}
+ onLongPress={drag}
+ disabled={isActive}
+ className='flex-row items-center px-4 py-3'
+ style={{
+ opacity: isPast && !isActive ? 0.5 : 1,
+ backgroundColor: isActive
+ ? "#2a2a2a"
+ : isCurrentTrack
+ ? "rgba(147, 52, 233, 0.3)"
+ : "#121212",
+ }}
+ >
+ {/* Drag handle */}
onRemoveFromQueue(index)}
+ onPressIn={drag}
+ disabled={isActive}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
- className='p-2'
+ className='pr-2'
>
-
+
- )}
-
+
+ {/* Album art */}
+
+ {imageUrl ? (
+
+ ) : (
+
+
+
+ )}
+
+
+ {/* Track info */}
+
+
+ {item.Name}
+
+
+ {item.Artists?.join(", ") || item.AlbumArtist}
+
+
+
+ {/* Now playing indicator */}
+ {isCurrentTrack && (
+
+ )}
+
+ {/* Remove button (not for current track) */}
+ {!isCurrentTrack && (
+ onRemoveFromQueue(index)}
+ hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
+ className='p-2'
+ >
+
+
+ )}
+
+
);
},
[api, queueIndex, onJumpToIndex, onRemoveFromQueue],
);
- const _upNext = queue.slice(queueIndex + 1);
+ const handleDragEnd = useCallback(
+ ({ data }: { data: BaseItemDto[] }) => {
+ onReorderQueue(data);
+ },
+ [onReorderQueue],
+ );
+
const history = queue.slice(0, queueIndex);
return (
- `${item.Id}-${index}`}
renderItem={renderQueueItem}
+ onDragEnd={handleDragEnd}
showsVerticalScrollIndicator={false}
- initialScrollIndex={queueIndex > 2 ? queueIndex - 2 : 0}
- getItemLayout={(_, index) => ({
- length: 72,
- offset: 72 * index,
- index,
- })}
ListHeaderComponent={
diff --git a/bun.lock b/bun.lock
index 0686dee9..48290638 100644
--- a/bun.lock
+++ b/bun.lock
@@ -61,6 +61,7 @@
"react-native-collapsible": "^1.6.2",
"react-native-country-flag": "^2.0.2",
"react-native-device-info": "^15.0.0",
+ "react-native-draggable-flatlist": "^4.0.3",
"react-native-edge-to-edge": "^1.7.0",
"react-native-gesture-handler": "~2.28.0",
"react-native-glass-effect-view": "^1.0.0",
@@ -1637,6 +1638,8 @@
"react-native-device-info": ["react-native-device-info@15.0.1", "", { "peerDependencies": { "react-native": "*" } }, "sha512-U5waZRXtT3l1SgZpZMlIvMKPTkFZPH8W7Ks6GrJhdH723aUIPxjVer7cRSij1mvQdOAAYFJV/9BDzlC8apG89A=="],
+ "react-native-draggable-flatlist": ["react-native-draggable-flatlist@4.0.3", "", { "dependencies": { "@babel/preset-typescript": "^7.17.12" }, "peerDependencies": { "react-native": ">=0.64.0", "react-native-gesture-handler": ">=2.0.0", "react-native-reanimated": ">=2.8.0" } }, "sha512-2F4x5BFieWdGq9SetD2nSAR7s7oQCSgNllYgERRXXtNfSOuAGAVbDb/3H3lP0y5f7rEyNwabKorZAD/SyyNbDw=="],
+
"react-native-edge-to-edge": ["react-native-edge-to-edge@1.7.0", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-ERegbsq28yoMndn/Uq49i4h6aAhMvTEjOfkFh50yX9H/dMjjCr/Tix/es/9JcPRvC+q7VzCMWfxWDUb6Jrq1OQ=="],
"react-native-gesture-handler": ["react-native-gesture-handler@2.28.0", "", { "dependencies": { "@egjs/hammerjs": "^2.0.17", "hoist-non-react-statics": "^3.3.0", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-0msfJ1vRxXKVgTgvL+1ZOoYw3/0z1R+Ked0+udoJhyplC2jbVKIJ8Z1bzWdpQRCV3QcQ87Op0zJVE5DhKK2A0A=="],
diff --git a/components/music/MusicPlaybackEngine.tsx b/components/music/MusicPlaybackEngine.tsx
index 0a1298c8..e136d422 100644
--- a/components/music/MusicPlaybackEngine.tsx
+++ b/components/music/MusicPlaybackEngine.tsx
@@ -99,6 +99,7 @@ export const MusicPlaybackEngine: React.FC = () => {
currentIndex,
);
await TrackPlayer.skip(currentIndex);
+ await TrackPlayer.play();
}
} catch (error) {
console.warn(
diff --git a/components/music/TrackOptionsSheet.tsx b/components/music/TrackOptionsSheet.tsx
index 05d8ec98..b0f5b91b 100644
--- a/components/music/TrackOptionsSheet.tsx
+++ b/components/music/TrackOptionsSheet.tsx
@@ -27,6 +27,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import { useFavorite } from "@/hooks/useFavorite";
import {
+ audioStorageEvents,
downloadTrack,
isCached,
isPermanentDownloading,
@@ -62,6 +63,22 @@ export const TrackOptionsSheet: React.FC = ({
const insets = useSafeAreaInsets();
const { t } = useTranslation();
const [isDownloadingTrack, setIsDownloadingTrack] = useState(false);
+ // Counter to trigger re-evaluation of download status when storage changes
+ const [storageUpdateCounter, setStorageUpdateCounter] = useState(0);
+
+ // Listen for storage events to update download status
+ useEffect(() => {
+ const handleComplete = (event: { itemId: string }) => {
+ if (event.itemId === track?.Id) {
+ setStorageUpdateCounter((c) => c + 1);
+ }
+ };
+
+ audioStorageEvents.on("complete", handleComplete);
+ return () => {
+ audioStorageEvents.off("complete", handleComplete);
+ };
+ }, [track?.Id]);
// Use a placeholder item for useFavorite when track is null
const { isFavorite, toggleFavorite } = useFavorite(
@@ -70,15 +87,18 @@ export const TrackOptionsSheet: React.FC = ({
const snapPoints = useMemo(() => ["65%"], []);
- // Check download status
+ // Check download status (storageUpdateCounter triggers re-evaluation when download completes)
const isAlreadyDownloaded = useMemo(
() => isPermanentlyDownloaded(track?.Id),
- [track?.Id],
+ [track?.Id, storageUpdateCounter],
+ );
+ const isOnlyCached = useMemo(
+ () => isCached(track?.Id),
+ [track?.Id, storageUpdateCounter],
);
- const isOnlyCached = useMemo(() => isCached(track?.Id), [track?.Id]);
const isCurrentlyDownloading = useMemo(
() => isPermanentDownloading(track?.Id),
- [track?.Id],
+ [track?.Id, storageUpdateCounter],
);
const imageUrl = useMemo(() => {
@@ -150,7 +170,10 @@ export const TrackOptionsSheet: React.FC = ({
try {
const result = await getAudioStreamUrl(api, user.Id, track.Id);
if (result?.url && !result.isTranscoding) {
- await downloadTrack(track.Id, result.url, { permanent: true });
+ await downloadTrack(track.Id, result.url, {
+ permanent: true,
+ container: result.mediaSource?.Container || undefined,
+ });
}
} catch {
// Silent fail
diff --git a/package.json b/package.json
index dce3c756..58ed6d0d 100644
--- a/package.json
+++ b/package.json
@@ -80,6 +80,7 @@
"react-native-collapsible": "^1.6.2",
"react-native-country-flag": "^2.0.2",
"react-native-device-info": "^15.0.0",
+ "react-native-draggable-flatlist": "^4.0.3",
"react-native-edge-to-edge": "^1.7.0",
"react-native-gesture-handler": "~2.28.0",
"react-native-glass-effect-view": "^1.0.0",
diff --git a/providers/AudioStorage/index.ts b/providers/AudioStorage/index.ts
index 8b9f50d3..4a530c90 100644
--- a/providers/AudioStorage/index.ts
+++ b/providers/AudioStorage/index.ts
@@ -34,7 +34,10 @@ const AUDIO_PERMANENT_DIR = "streamyfin-audio";
// Default limits
const DEFAULT_MAX_CACHE_TRACKS = 10;
-const DEFAULT_MAX_CACHE_SIZE_BYTES = 100 * 1024 * 1024; // 100MB
+const DEFAULT_MAX_CACHE_SIZE_BYTES = 500 * 1024 * 1024; // 500MB
+
+// Configurable limits (can be updated at runtime)
+let configuredMaxCacheSizeBytes = DEFAULT_MAX_CACHE_SIZE_BYTES;
// Event emitter for notifying about download completion
class AudioStorageEventEmitter extends EventEmitter<{
@@ -130,6 +133,17 @@ async function ensureDirectories(): Promise {
}
}
+/**
+ * Set the maximum cache size in megabytes
+ * Call this when settings change
+ */
+export function setMaxCacheSizeMB(sizeMB: number): void {
+ configuredMaxCacheSizeBytes = sizeMB * 1024 * 1024;
+ console.log(
+ `[AudioStorage] Max cache size set to ${sizeMB}MB (${configuredMaxCacheSizeBytes} bytes)`,
+ );
+}
+
/**
* Initialize audio storage - call this on app startup
*/
@@ -447,9 +461,11 @@ export async function downloadTrack(
return;
}
- // Use .m4a extension - compatible with iOS/Android and most audio formats
- const filename = `${itemId}.m4a`;
- const destinationPath = `${targetDir.uri}/${filename}`.replace("file://", "");
+ // Use the actual container format as extension, fallback to m4a
+ const extension = options.container?.toLowerCase() || "m4a";
+ const filename = `${itemId}.${extension}`;
+ const destinationPath =
+ `${targetDir.uri.replace(/\/$/, "")}/${filename}`.replace("file://", "");
console.log(
`[AudioStorage] Starting download: ${itemId} (permanent=${permanent})`,
@@ -529,7 +545,7 @@ export async function deleteTrack(itemId: string): Promise {
*/
async function evictCacheIfNeeded(
maxTracks: number = DEFAULT_MAX_CACHE_TRACKS,
- maxSizeBytes: number = DEFAULT_MAX_CACHE_SIZE_BYTES,
+ maxSizeBytes: number = configuredMaxCacheSizeBytes,
): Promise {
const index = getStorageIndex();
diff --git a/providers/AudioStorage/types.ts b/providers/AudioStorage/types.ts
index c93f7f0a..edb80195 100644
--- a/providers/AudioStorage/types.ts
+++ b/providers/AudioStorage/types.ts
@@ -22,6 +22,7 @@ export interface AudioStorageIndex {
export interface DownloadOptions {
permanent: boolean;
+ container?: string; // File extension/format (e.g., "mp3", "flac", "m4a")
}
export interface DownloadCompleteEvent {
diff --git a/providers/MusicPlayerProvider.tsx b/providers/MusicPlayerProvider.tsx
index 5a134e5a..223efcc6 100644
--- a/providers/MusicPlayerProvider.tsx
+++ b/providers/MusicPlayerProvider.tsx
@@ -26,6 +26,7 @@ import {
getLocalPath,
initAudioStorage,
isDownloading,
+ setMaxCacheSizeMB,
} from "@/providers/AudioStorage";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { settingsAtom } from "@/utils/atoms/settings";
@@ -86,6 +87,7 @@ interface MusicPlayerContextType extends MusicPlayerState {
playNext: (tracks: BaseItemDto | BaseItemDto[]) => void;
removeFromQueue: (index: number) => void;
moveInQueue: (fromIndex: number, toIndex: number) => void;
+ reorderQueue: (newQueue: BaseItemDto[]) => void;
clearQueue: () => void;
jumpToIndex: (index: number) => void;
@@ -286,7 +288,12 @@ export const MusicPlayerProvider: React.FC = ({
// Initialize audio storage for caching
await initAudioStorage();
- await TrackPlayer.setupPlayer();
+ await TrackPlayer.setupPlayer({
+ minBuffer: 120, // Minimum 2 minutes buffer for network resilience
+ maxBuffer: 240, // Maximum 4 minutes buffer
+ playBuffer: 5, // Start playback after 5 seconds buffered
+ backBuffer: 30, // Keep 30 seconds behind for seeking
+ });
await TrackPlayer.updateOptions({
capabilities: [
Capability.Play,
@@ -313,6 +320,13 @@ export const MusicPlayerProvider: React.FC = ({
setupPlayer();
}, []);
+ // Update audio cache size when settings change
+ useEffect(() => {
+ if (settings?.audioMaxCacheSizeMB) {
+ setMaxCacheSizeMB(settings.audioMaxCacheSizeMB);
+ }
+ }, [settings?.audioMaxCacheSizeMB]);
+
// Sync repeat mode to TrackPlayer
useEffect(() => {
const syncRepeatMode = async () => {
@@ -476,9 +490,15 @@ export const MusicPlayerProvider: React.FC = ({
const item = queue[i];
if (!item.Id) continue;
- // First check for cached version (for offline fallback)
+ // Check for cached/downloaded version
const cachedUrl = getLocalPath(item.Id);
+ // If preferLocal and we have a local file, use it directly without server request
+ if (preferLocal && cachedUrl) {
+ tracks.push(itemToTrack(item, cachedUrl, api, true));
+ continue;
+ }
+
// Try to get stream URL from server
const result = await getAudioStreamUrl(api, user.Id, item.Id);
@@ -545,7 +565,8 @@ export const MusicPlayerProvider: React.FC = ({
}));
reportPlaybackStart(currentTrack, state.playSessionId);
- } catch (_error) {
+ } catch (error) {
+ console.error("[MusicPlayer] Error loading queue:", error);
setState((prev) => ({
...prev,
isLoading: false,
@@ -1043,6 +1064,63 @@ export const MusicPlayerProvider: React.FC = ({
[],
);
+ // Reorder queue with a new array (used by drag-to-reorder UI)
+ const reorderQueue = useCallback(
+ async (newQueue: BaseItemDto[]) => {
+ // Find where the current track ended up in the new order
+ const currentTrackId = state.currentTrack?.Id;
+ const newIndex = currentTrackId
+ ? newQueue.findIndex((t) => t.Id === currentTrackId)
+ : 0;
+
+ // Build the reordering operations for TrackPlayer
+ // We need to match TrackPlayer's queue to the new order
+ const tpQueue = await TrackPlayer.getQueue();
+
+ // Create a map of trackId -> current TrackPlayer index
+ const currentPositions = new Map();
+ tpQueue.forEach((track, idx) => {
+ currentPositions.set(track.id, idx);
+ });
+
+ // Move tracks one by one to match the new order
+ // Work backwards to avoid index shifting issues
+ for (let targetIdx = newQueue.length - 1; targetIdx >= 0; targetIdx--) {
+ const trackId = newQueue[targetIdx].Id;
+ if (!trackId) continue;
+
+ const currentIdx = currentPositions.get(trackId);
+ if (currentIdx !== undefined && currentIdx !== targetIdx) {
+ await TrackPlayer.move(currentIdx, targetIdx);
+
+ // Update positions map after move
+ currentPositions.forEach((pos, id) => {
+ if (currentIdx < targetIdx) {
+ // Moving down: items between shift up
+ if (pos > currentIdx && pos <= targetIdx) {
+ currentPositions.set(id, pos - 1);
+ }
+ } else {
+ // Moving up: items between shift down
+ if (pos >= targetIdx && pos < currentIdx) {
+ currentPositions.set(id, pos + 1);
+ }
+ }
+ });
+ currentPositions.set(trackId, targetIdx);
+ }
+ }
+
+ setState((prev) => ({
+ ...prev,
+ queue: newQueue,
+ queueIndex: newIndex >= 0 ? newIndex : 0,
+ currentTrack: newIndex >= 0 ? newQueue[newIndex] : prev.currentTrack,
+ }));
+ },
+ [state.currentTrack?.Id],
+ );
+
const clearQueue = useCallback(async () => {
const currentIndex = await TrackPlayer.getActiveTrackIndex();
const queue = await TrackPlayer.getQueue();
@@ -1181,7 +1259,7 @@ 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
+ // Look-ahead cache: pre-cache upcoming N tracks (excludes current track to avoid bandwidth competition)
const triggerLookahead = useCallback(async () => {
// Check if caching is enabled in settings
if (settings?.audioLookaheadEnabled === false) return;
@@ -1192,10 +1270,10 @@ export const MusicPlayerProvider: React.FC = ({
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;
+ // Cache next N tracks (from settings, default 1) - excludes current to avoid bandwidth competition
+ const lookaheadCount = settings?.audioLookaheadCount ?? 1;
const tracksToCache = tpQueue.slice(
- currentIdx,
+ currentIdx + 1,
currentIdx + 1 + lookaheadCount,
);
@@ -1209,7 +1287,10 @@ export const MusicPlayerProvider: React.FC = ({
// Only cache direct streams (not transcoding - can't cache dynamic content)
if (result?.url && !result.isTranscoding) {
- downloadTrack(itemId, result.url, { permanent: false }).catch(() => {
+ downloadTrack(itemId, result.url, {
+ permanent: false,
+ container: result.mediaSource?.Container || undefined,
+ }).catch(() => {
// Silent fail - caching is best-effort
});
}
@@ -1242,6 +1323,7 @@ export const MusicPlayerProvider: React.FC = ({
playNext,
removeFromQueue,
moveInQueue,
+ reorderQueue,
clearQueue,
jumpToIndex,
setRepeatMode,
@@ -1271,6 +1353,7 @@ export const MusicPlayerProvider: React.FC = ({
playNext,
removeFromQueue,
moveInQueue,
+ reorderQueue,
clearQueue,
jumpToIndex,
setRepeatMode,
diff --git a/translations/en.json b/translations/en.json
index bdcea73c..9261e248 100644
--- a/translations/en.json
+++ b/translations/en.json
@@ -232,7 +232,9 @@
"prefer_downloaded": "Prefer Downloaded Songs",
"caching_title": "Caching",
"caching_description": "Automatically cache upcoming tracks for smoother playback.",
- "lookahead_enabled": "Enable Look-Ahead Caching"
+ "lookahead_enabled": "Enable Look-Ahead Caching",
+ "lookahead_count": "Tracks to Pre-cache",
+ "max_cache_size": "Max Cache Size"
},
"plugins": {
"plugins_title": "Plugins",
diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts
index 4719a99a..f2e8d332 100644
--- a/utils/atoms/settings.ts
+++ b/utils/atoms/settings.ts
@@ -294,8 +294,8 @@ export const defaultValues: Settings = {
hideWatchlistsTab: false,
// Audio look-ahead caching defaults
audioLookaheadEnabled: true,
- audioLookaheadCount: 2,
- audioMaxCacheSizeMB: 100,
+ audioLookaheadCount: 1,
+ audioMaxCacheSizeMB: 500,
// Music playback
preferLocalAudio: true,
};