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, };