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

View File

@@ -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<Props> = ({
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<Props> = ({
};
}, [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<Props> = ({
</Text>
</View>
{/* Download status indicator */}
{/* Download/cache status indicator */}
{downloadStatus === "downloading" && (
<ActivityIndicator
size={14}

View File

@@ -28,6 +28,7 @@ import { Text } from "@/components/common/Text";
import { useFavorite } from "@/hooks/useFavorite";
import {
audioStorageEvents,
deleteTrack,
downloadTrack,
isCached,
isPermanentDownloading,
@@ -80,6 +81,11 @@ export const TrackOptionsSheet: React.FC<Props> = ({
};
}, [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<Props> = ({
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<Props> = ({
</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>
{/* Navigation Options */}