mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-15 15:48:05 +00:00
fix: cache issues with songs, partial downloads, component rerenders, etc
This commit is contained in:
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user