fix: cache issues with songs, partial downloads, component rerenders, etc

This commit is contained in:
Fredrik Burmester
2026-01-09 14:29:17 +01:00
parent f369738f7b
commit 241f8c949a
6 changed files with 176 additions and 22 deletions

View File

@@ -7,7 +7,11 @@ import TrackPlayer, {
usePlaybackState, usePlaybackState,
useProgress, useProgress,
} from "react-native-track-player"; } from "react-native-track-player";
import { audioStorageEvents, getLocalPath } from "@/providers/AudioStorage"; import {
audioStorageEvents,
deleteTrack,
getLocalPath,
} from "@/providers/AudioStorage";
import { useMusicPlayer } from "@/providers/MusicPlayerProvider"; import { useMusicPlayer } from "@/providers/MusicPlayerProvider";
export const MusicPlaybackEngine: React.FC = () => { export const MusicPlaybackEngine: React.FC = () => {
@@ -168,6 +172,51 @@ export const MusicPlaybackEngine: React.FC = () => {
}; };
}, []); }, []);
// Listen for playback errors (corrupted cache files)
useEffect(() => {
const subscription = TrackPlayer.addEventListener(
Event.PlaybackError,
async (event) => {
const activeTrack = await TrackPlayer.getActiveTrack();
if (!activeTrack?.url) return;
// Only handle local file errors
const url = activeTrack.url as string;
if (!url.startsWith("file://")) return;
console.warn(
`[MusicPlayer] Playback error for cached file: ${activeTrack.id}`,
event,
);
// Delete corrupted cache file
if (activeTrack.id) {
try {
await deleteTrack(activeTrack.id);
console.log(
`[MusicPlayer] Deleted corrupted cache file: ${activeTrack.id}`,
);
} catch (error) {
console.warn(
"[MusicPlayer] Failed to delete corrupted file:",
error,
);
}
}
// Skip to next track
try {
await TrackPlayer.skipToNext();
} catch {
// No next track available, stop playback
await TrackPlayer.stop();
}
},
);
return () => subscription.remove();
}, []);
// No visual component needed - TrackPlayer is headless // No visual component needed - TrackPlayer is headless
return null; return null;
}; };

View File

@@ -11,6 +11,7 @@ import { useNetworkStatus } from "@/hooks/useNetworkStatus";
import { import {
audioStorageEvents, audioStorageEvents,
getLocalPath, getLocalPath,
isCached,
isPermanentDownloading, isPermanentDownloading,
isPermanentlyDownloaded, isPermanentlyDownloaded,
} from "@/providers/AudioStorage"; } from "@/providers/AudioStorage";
@@ -52,20 +53,20 @@ export const MusicTrackItem: React.FC<Props> = ({
const isTrackLoading = loadingTrackId === track.Id; const isTrackLoading = loadingTrackId === track.Id;
// Track download status with reactivity to completion events // Track download status with reactivity to completion events
// Only track permanent downloads - we don't show UI for auto-caching
const [downloadStatus, setDownloadStatus] = useState< const [downloadStatus, setDownloadStatus] = useState<
"none" | "downloading" | "downloaded" "none" | "downloading" | "downloaded" | "cached"
>(() => { >(() => {
if (isPermanentlyDownloaded(track.Id)) return "downloaded"; if (isPermanentlyDownloaded(track.Id)) return "downloaded";
if (isPermanentDownloading(track.Id)) return "downloading"; if (isPermanentDownloading(track.Id)) return "downloading";
if (isCached(track.Id)) return "cached";
return "none"; return "none";
}); });
// Listen for download completion/error events (only for permanent downloads) // Listen for download completion/error events
useEffect(() => { useEffect(() => {
const onComplete = (event: { itemId: string; permanent: boolean }) => { const onComplete = (event: { itemId: string; permanent: boolean }) => {
if (event.itemId === track.Id && event.permanent) { if (event.itemId === track.Id) {
setDownloadStatus("downloaded"); setDownloadStatus(event.permanent ? "downloaded" : "cached");
} }
}; };
const onError = (event: { itemId: string }) => { const onError = (event: { itemId: string }) => {
@@ -83,12 +84,18 @@ export const MusicTrackItem: React.FC<Props> = ({
}; };
}, [track.Id]); }, [track.Id]);
// Also check periodically if permanent download started (for when download is triggered externally) // Re-check status when track changes (for list item recycling)
useEffect(() => { useEffect(() => {
if (downloadStatus === "none" && isPermanentDownloading(track.Id)) { if (isPermanentlyDownloaded(track.Id)) {
setDownloadStatus("downloaded");
} else if (isPermanentDownloading(track.Id)) {
setDownloadStatus("downloading"); setDownloadStatus("downloading");
} else if (isCached(track.Id)) {
setDownloadStatus("cached");
} else {
setDownloadStatus("none");
} }
}); }, [track.Id]);
const _isDownloaded = downloadStatus === "downloaded"; const _isDownloaded = downloadStatus === "downloaded";
// Check if available locally (either cached or permanently downloaded) // Check if available locally (either cached or permanently downloaded)
@@ -184,7 +191,7 @@ export const MusicTrackItem: React.FC<Props> = ({
</Text> </Text>
</View> </View>
{/* Download status indicator */} {/* Download/cache status indicator */}
{downloadStatus === "downloading" && ( {downloadStatus === "downloading" && (
<ActivityIndicator <ActivityIndicator
size={14} size={14}

View File

@@ -28,6 +28,7 @@ import { Text } from "@/components/common/Text";
import { useFavorite } from "@/hooks/useFavorite"; import { useFavorite } from "@/hooks/useFavorite";
import { import {
audioStorageEvents, audioStorageEvents,
deleteTrack,
downloadTrack, downloadTrack,
isCached, isCached,
isPermanentDownloading, isPermanentDownloading,
@@ -80,6 +81,11 @@ export const TrackOptionsSheet: React.FC<Props> = ({
}; };
}, [track?.Id]); }, [track?.Id]);
// Force re-evaluation of cache status when track changes
useEffect(() => {
setStorageUpdateCounter((c) => c + 1);
}, [track?.Id]);
// Use a placeholder item for useFavorite when track is null // Use a placeholder item for useFavorite when track is null
const { isFavorite, toggleFavorite } = useFavorite( const { isFavorite, toggleFavorite } = useFavorite(
track ?? ({ Id: "", UserData: { IsFavorite: false } } as BaseItemDto), track ?? ({ Id: "", UserData: { IsFavorite: false } } as BaseItemDto),
@@ -180,6 +186,13 @@ export const TrackOptionsSheet: React.FC<Props> = ({
setOpen(false); setOpen(false);
}, [track?.Id, api, user?.Id, isAlreadyDownloaded, setOpen]); }, [track?.Id, api, user?.Id, isAlreadyDownloaded, setOpen]);
const handleDelete = useCallback(async () => {
if (!track?.Id) return;
await deleteTrack(track.Id);
setStorageUpdateCounter((c) => c + 1);
setOpen(false);
}, [track?.Id, setOpen]);
const handleGoToArtist = useCallback(() => { const handleGoToArtist = useCallback(() => {
const artistId = track?.ArtistItems?.[0]?.Id; const artistId = track?.ArtistItems?.[0]?.Id;
if (artistId) { if (artistId) {
@@ -388,6 +401,23 @@ export const TrackOptionsSheet: React.FC<Props> = ({
</View> </View>
</> </>
)} )}
{(isAlreadyDownloaded || isOnlyCached) && (
<>
<View style={styles.separator} />
<TouchableOpacity
onPress={handleDelete}
className='flex-row items-center px-4 py-3.5'
>
<Ionicons name='trash-outline' size={22} color='#ef4444' />
<Text className='text-red-500 ml-4 text-base'>
{isAlreadyDownloaded
? t("music.track_options.delete_download")
: t("music.track_options.delete_cache")}
</Text>
</TouchableOpacity>
</>
)}
</View> </View>
{/* Navigation Options */} {/* Navigation Options */}

View File

@@ -402,8 +402,18 @@ export function isCached(itemId: string | undefined): boolean {
if (file.exists) { if (file.exists) {
return true; return true;
} }
// File doesn't exist - clean up stale index entry
console.log(
`[AudioStorage] Cleaning up stale cache entry: ${itemId} (file missing)`,
);
index.totalCacheSize -= info.size;
delete index.tracks[itemId];
saveStorageIndex();
} catch { } catch {
// File doesn't exist // File check failed - clean up stale index entry
index.totalCacheSize -= info.size;
delete index.tracks[itemId];
saveStorageIndex();
} }
} }
} catch { } catch {

View File

@@ -556,9 +556,11 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
if (!api || !user?.Id) return; if (!api || !user?.Id) return;
const mediaInfoMap: Record<string, TrackMediaInfo> = {}; const mediaInfoMap: Record<string, TrackMediaInfo> = {};
const failedItemIds: string[] = []; // Track items that failed to prepare
// Process tracks BEFORE the start index (insert at position 0, pushing current track forward) // Process tracks BEFORE the start index (insert at position 0, pushing current track forward)
const beforeTracks: Track[] = []; const beforeTracks: Track[] = [];
const beforeSuccessIds: string[] = []; // Track successful IDs to maintain order
for (let i = 0; i < startIndex; i++) { for (let i = 0; i < startIndex; i++) {
const item = queue[i]; const item = queue[i];
if (!item.Id) continue; if (!item.Id) continue;
@@ -566,9 +568,12 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
const prepared = await prepareTrack(item, preferLocal); const prepared = await prepareTrack(item, preferLocal);
if (prepared) { if (prepared) {
beforeTracks.push(prepared.track); beforeTracks.push(prepared.track);
beforeSuccessIds.push(item.Id);
if (prepared.mediaInfo) { if (prepared.mediaInfo) {
mediaInfoMap[item.Id] = prepared.mediaInfo; mediaInfoMap[item.Id] = prepared.mediaInfo;
} }
} else {
failedItemIds.push(item.Id);
} }
} }
@@ -600,8 +605,36 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
}, },
})); }));
} }
} else {
failedItemIds.push(item.Id);
} }
} }
// Remove failed items from queue to keep it in sync with TrackPlayer
if (failedItemIds.length > 0) {
console.log(
`[MusicPlayer] Removing ${failedItemIds.length} unavailable tracks from queue`,
);
setState((prev) => {
const newQueue = prev.queue.filter(
(t) => !failedItemIds.includes(t.Id!),
);
const newOriginalQueue = prev.originalQueue.filter(
(t) => !failedItemIds.includes(t.Id!),
);
// Recalculate queue index based on current track position in filtered queue
const currentTrackId = prev.currentTrack?.Id;
const newQueueIndex = currentTrackId
? newQueue.findIndex((t) => t.Id === currentTrackId)
: 0;
return {
...prev,
queue: newQueue,
originalQueue: newOriginalQueue,
queueIndex: newQueueIndex >= 0 ? newQueueIndex : prev.queueIndex,
};
});
}
}, },
[api, user?.Id, prepareTrack], [api, user?.Id, prepareTrack],
); );
@@ -610,7 +643,26 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
async (queue: BaseItemDto[], startIndex: number) => { async (queue: BaseItemDto[], startIndex: number) => {
if (!api || !user?.Id || queue.length === 0) return; if (!api || !user?.Id || queue.length === 0) return;
const targetItem = queue[startIndex]; const preferLocal = settings?.preferLocalAudio ?? true;
// Apply offline filtering at the start to ensure state.queue matches TrackPlayer queue
let finalQueue = queue;
let finalIndex = startIndex;
if (isOffline) {
const filtered = filterQueueForOffline(queue, startIndex);
finalQueue = filtered.queue;
finalIndex = filtered.startIndex;
if (finalQueue.length === 0) {
console.warn(
"[MusicPlayer] No downloaded tracks available for offline playback",
);
return;
}
}
const targetItem = finalQueue[finalIndex];
setState((prev) => ({ setState((prev) => ({
...prev, ...prev,
isLoading: true, isLoading: true,
@@ -618,8 +670,6 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
})); }));
try { try {
const preferLocal = settings?.preferLocalAudio ?? true;
// PHASE 1: Prepare and play the target track immediately // PHASE 1: Prepare and play the target track immediately
const targetTrackResult = await prepareTrack(targetItem, preferLocal); const targetTrackResult = await prepareTrack(targetItem, preferLocal);
@@ -640,8 +690,8 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
// Update state for immediate playback // Update state for immediate playback
setState((prev) => ({ setState((prev) => ({
...prev, ...prev,
queue, queue: finalQueue,
originalQueue: queue, originalQueue: finalQueue,
queueIndex: 0, // Target track is at index 0 in TrackPlayer initially queueIndex: 0, // Target track is at index 0 in TrackPlayer initially
currentTrack: targetItem, currentTrack: targetItem,
isLoading: false, isLoading: false,
@@ -663,8 +713,8 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
reportPlaybackStart(targetItem, targetTrackResult.sessionId); reportPlaybackStart(targetItem, targetTrackResult.sessionId);
// PHASE 2: Load remaining tracks in background (non-blocking) // PHASE 2: Load remaining tracks in background (non-blocking)
if (queue.length > 1) { if (finalQueue.length > 1) {
loadRemainingTracksInBackground(queue, startIndex, preferLocal); loadRemainingTracksInBackground(finalQueue, finalIndex, preferLocal);
} }
} catch (error) { } catch (error) {
console.error("[MusicPlayer] Error loading queue:", error); console.error("[MusicPlayer] Error loading queue:", error);
@@ -682,6 +732,7 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
settings?.preferLocalAudio, settings?.preferLocalAudio,
prepareTrack, prepareTrack,
loadRemainingTracksInBackground, loadRemainingTracksInBackground,
isOffline,
], ],
); );
@@ -1407,13 +1458,18 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
}, []); }, []);
// Sync state from TrackPlayer (called when active track changes) // Sync state from TrackPlayer (called when active track changes)
// Uses ID-based lookup instead of index to handle queue mismatches
const syncFromTrackPlayer = useCallback(async () => { const syncFromTrackPlayer = useCallback(async () => {
const index = await TrackPlayer.getActiveTrackIndex(); const activeTrack = await TrackPlayer.getActiveTrack();
if (index !== undefined && index < state.queue.length) { if (!activeTrack?.id) return;
// Find track by ID, not by index - handles cases where queues have different tracks
const trackIndex = state.queue.findIndex((t) => t.Id === activeTrack.id);
if (trackIndex >= 0) {
setState((prev) => ({ setState((prev) => ({
...prev, ...prev,
queueIndex: index, queueIndex: trackIndex,
currentTrack: prev.queue[index], currentTrack: prev.queue[trackIndex],
})); }));
} }
}, [state.queue]); }, [state.queue]);

View File

@@ -724,6 +724,8 @@
"downloaded": "Downloaded", "downloaded": "Downloaded",
"downloading": "Downloading...", "downloading": "Downloading...",
"cached": "Cached", "cached": "Cached",
"delete_download": "Delete Download",
"delete_cache": "Remove from Cache",
"go_to_artist": "Go to Artist", "go_to_artist": "Go to Artist",
"go_to_album": "Go to Album", "go_to_album": "Go to Album",
"add_to_favorites": "Add to Favorites", "add_to_favorites": "Add to Favorites",