diff --git a/components/music/MusicPlaybackEngine.tsx b/components/music/MusicPlaybackEngine.tsx index e136d422..ae1b07cd 100644 --- a/components/music/MusicPlaybackEngine.tsx +++ b/components/music/MusicPlaybackEngine.tsx @@ -7,7 +7,11 @@ import TrackPlayer, { usePlaybackState, useProgress, } from "react-native-track-player"; -import { audioStorageEvents, getLocalPath } from "@/providers/AudioStorage"; +import { + audioStorageEvents, + deleteTrack, + getLocalPath, +} from "@/providers/AudioStorage"; import { useMusicPlayer } from "@/providers/MusicPlayerProvider"; 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 return null; }; diff --git a/components/music/MusicTrackItem.tsx b/components/music/MusicTrackItem.tsx index cf6b1e23..a3f80efc 100644 --- a/components/music/MusicTrackItem.tsx +++ b/components/music/MusicTrackItem.tsx @@ -11,6 +11,7 @@ import { useNetworkStatus } from "@/hooks/useNetworkStatus"; import { audioStorageEvents, getLocalPath, + isCached, isPermanentDownloading, isPermanentlyDownloaded, } from "@/providers/AudioStorage"; @@ -52,20 +53,20 @@ export const MusicTrackItem: React.FC = ({ 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" + "none" | "downloading" | "downloaded" | "cached" >(() => { if (isPermanentlyDownloaded(track.Id)) return "downloaded"; if (isPermanentDownloading(track.Id)) return "downloading"; + if (isCached(track.Id)) return "cached"; return "none"; }); - // Listen for download completion/error events (only for permanent downloads) + // Listen for download completion/error events useEffect(() => { const onComplete = (event: { itemId: string; permanent: boolean }) => { - if (event.itemId === track.Id && event.permanent) { - setDownloadStatus("downloaded"); + if (event.itemId === track.Id) { + setDownloadStatus(event.permanent ? "downloaded" : "cached"); } }; const onError = (event: { itemId: string }) => { @@ -83,12 +84,18 @@ export const MusicTrackItem: React.FC = ({ }; }, [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(() => { - if (downloadStatus === "none" && isPermanentDownloading(track.Id)) { + if (isPermanentlyDownloaded(track.Id)) { + setDownloadStatus("downloaded"); + } else if (isPermanentDownloading(track.Id)) { setDownloadStatus("downloading"); + } else if (isCached(track.Id)) { + setDownloadStatus("cached"); + } else { + setDownloadStatus("none"); } - }); + }, [track.Id]); const _isDownloaded = downloadStatus === "downloaded"; // Check if available locally (either cached or permanently downloaded) @@ -184,7 +191,7 @@ export const MusicTrackItem: React.FC = ({ - {/* Download status indicator */} + {/* Download/cache status indicator */} {downloadStatus === "downloading" && ( = ({ }; }, [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 const { isFavorite, toggleFavorite } = useFavorite( track ?? ({ Id: "", UserData: { IsFavorite: false } } as BaseItemDto), @@ -180,6 +186,13 @@ export const TrackOptionsSheet: React.FC = ({ setOpen(false); }, [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 artistId = track?.ArtistItems?.[0]?.Id; if (artistId) { @@ -388,6 +401,23 @@ export const TrackOptionsSheet: React.FC = ({ )} + + {(isAlreadyDownloaded || isOnlyCached) && ( + <> + + + + + {isAlreadyDownloaded + ? t("music.track_options.delete_download") + : t("music.track_options.delete_cache")} + + + + )} {/* Navigation Options */} diff --git a/providers/AudioStorage/index.ts b/providers/AudioStorage/index.ts index 4a530c90..655dc876 100644 --- a/providers/AudioStorage/index.ts +++ b/providers/AudioStorage/index.ts @@ -402,8 +402,18 @@ export function isCached(itemId: string | undefined): boolean { if (file.exists) { 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 { - // File doesn't exist + // File check failed - clean up stale index entry + index.totalCacheSize -= info.size; + delete index.tracks[itemId]; + saveStorageIndex(); } } } catch { diff --git a/providers/MusicPlayerProvider.tsx b/providers/MusicPlayerProvider.tsx index 49c96ed7..63871a22 100644 --- a/providers/MusicPlayerProvider.tsx +++ b/providers/MusicPlayerProvider.tsx @@ -556,9 +556,11 @@ export const MusicPlayerProvider: React.FC = ({ if (!api || !user?.Id) return; const mediaInfoMap: Record = {}; + const failedItemIds: string[] = []; // Track items that failed to prepare // Process tracks BEFORE the start index (insert at position 0, pushing current track forward) const beforeTracks: Track[] = []; + const beforeSuccessIds: string[] = []; // Track successful IDs to maintain order for (let i = 0; i < startIndex; i++) { const item = queue[i]; if (!item.Id) continue; @@ -566,9 +568,12 @@ export const MusicPlayerProvider: React.FC = ({ const prepared = await prepareTrack(item, preferLocal); if (prepared) { beforeTracks.push(prepared.track); + beforeSuccessIds.push(item.Id); if (prepared.mediaInfo) { mediaInfoMap[item.Id] = prepared.mediaInfo; } + } else { + failedItemIds.push(item.Id); } } @@ -600,8 +605,36 @@ export const MusicPlayerProvider: React.FC = ({ }, })); } + } 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], ); @@ -610,7 +643,26 @@ export const MusicPlayerProvider: React.FC = ({ async (queue: BaseItemDto[], startIndex: number) => { 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) => ({ ...prev, isLoading: true, @@ -618,8 +670,6 @@ export const MusicPlayerProvider: React.FC = ({ })); try { - const preferLocal = settings?.preferLocalAudio ?? true; - // PHASE 1: Prepare and play the target track immediately const targetTrackResult = await prepareTrack(targetItem, preferLocal); @@ -640,8 +690,8 @@ export const MusicPlayerProvider: React.FC = ({ // Update state for immediate playback setState((prev) => ({ ...prev, - queue, - originalQueue: queue, + queue: finalQueue, + originalQueue: finalQueue, queueIndex: 0, // Target track is at index 0 in TrackPlayer initially currentTrack: targetItem, isLoading: false, @@ -663,8 +713,8 @@ export const MusicPlayerProvider: React.FC = ({ reportPlaybackStart(targetItem, targetTrackResult.sessionId); // PHASE 2: Load remaining tracks in background (non-blocking) - if (queue.length > 1) { - loadRemainingTracksInBackground(queue, startIndex, preferLocal); + if (finalQueue.length > 1) { + loadRemainingTracksInBackground(finalQueue, finalIndex, preferLocal); } } catch (error) { console.error("[MusicPlayer] Error loading queue:", error); @@ -682,6 +732,7 @@ export const MusicPlayerProvider: React.FC = ({ settings?.preferLocalAudio, prepareTrack, loadRemainingTracksInBackground, + isOffline, ], ); @@ -1407,13 +1458,18 @@ export const MusicPlayerProvider: React.FC = ({ }, []); // 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 index = await TrackPlayer.getActiveTrackIndex(); - if (index !== undefined && index < state.queue.length) { + const activeTrack = await TrackPlayer.getActiveTrack(); + 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) => ({ ...prev, - queueIndex: index, - currentTrack: prev.queue[index], + queueIndex: trackIndex, + currentTrack: prev.queue[trackIndex], })); } }, [state.queue]); diff --git a/translations/en.json b/translations/en.json index c7f144ff..d13ecc9a 100644 --- a/translations/en.json +++ b/translations/en.json @@ -724,6 +724,8 @@ "downloaded": "Downloaded", "downloading": "Downloading...", "cached": "Cached", + "delete_download": "Delete Download", + "delete_cache": "Remove from Cache", "go_to_artist": "Go to Artist", "go_to_album": "Go to Album", "add_to_favorites": "Add to Favorites",