fix: improve downloaded music queue
Some checks failed
🏗️ Build Apps / 🤖 Build Android APK (Phone) (push) Has been cancelled
🏗️ Build Apps / 🤖 Build Android APK (TV) (push) Has been cancelled
🏗️ Build Apps / 🍎 Build iOS IPA (Phone) (push) Has been cancelled
🔒 Lockfile Consistency Check / 🔍 Check bun.lock and package.json consistency (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (actions) (push) Has been cancelled
🛡️ CodeQL Analysis / 🔎 Analyze with CodeQL (javascript-typescript) (push) Has been cancelled
🏷️🔀Merge Conflict Labeler / 🏷️ Labeling Merge Conflicts (push) Has been cancelled
🌐 Translation Sync / sync-translations (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (lint) (push) Has been cancelled
🚦 Security & Quality Gate / 📝 Validate PR Title (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Vulnerable Dependencies (push) Has been cancelled
🚦 Security & Quality Gate / 🚑 Expo Doctor Check (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (check) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (format) (push) Has been cancelled
🚦 Security & Quality Gate / 🔍 Lint & Test (typecheck) (push) Has been cancelled

This commit is contained in:
Fredrik Burmester
2026-01-05 21:28:00 +01:00
parent 478052599f
commit 9bfac2a4d9

View File

@@ -29,6 +29,7 @@ import {
setMaxCacheSizeMB,
} from "@/providers/AudioStorage";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useNetworkStatus } from "@/providers/NetworkStatusProvider";
import { settingsAtom } from "@/utils/atoms/settings";
import { getAudioStreamUrl } from "@/utils/jellyfin/audio/getAudioStreamUrl";
import { storage } from "@/utils/mmkv";
@@ -49,6 +50,14 @@ interface TrackMediaInfo {
isTranscoding: boolean;
}
interface PreparedTrack {
track: Track;
sessionId: string | null;
mediaSource: MediaSourceInfo | null;
isTranscoding: boolean;
mediaInfo: TrackMediaInfo | null;
}
interface MusicPlayerState {
currentTrack: BaseItemDto | null;
queue: BaseItemDto[];
@@ -214,6 +223,20 @@ const shuffleArray = <T,>(array: T[], currentIndex: number): T[] => {
return result;
};
// Filter queue to only include downloaded items (for offline playback)
const filterQueueForOffline = (
queue: BaseItemDto[],
startIndex: number,
): { queue: BaseItemDto[]; startIndex: number } => {
const startItem = queue[startIndex];
const downloadedOnly = queue.filter((item) => getLocalPath(item.Id) !== null);
const newStartIndex = downloadedOnly.findIndex((t) => t.Id === startItem?.Id);
return {
queue: downloadedOnly,
startIndex: newStartIndex >= 0 ? newStartIndex : 0,
};
};
// Convert BaseItemDto to TrackPlayer Track
const itemToTrack = (
item: BaseItemDto,
@@ -255,6 +278,8 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
const api = useAtomValue(apiAtom);
const user = useAtomValue(userAtom);
const settings = useAtomValue(settingsAtom);
const { isConnected, serverConnected } = useNetworkStatus();
const isOffline = !isConnected || serverConnected === false;
const initializedRef = useRef(false);
const playerSetupRef = useRef(false);
@@ -466,71 +491,139 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
[api, user?.Id],
);
// Helper to prepare a single track - checks cache first, then fetches from server
const prepareTrack = useCallback(
async (
item: BaseItemDto,
preferLocal: boolean,
): Promise<PreparedTrack | null> => {
if (!api || !user?.Id || !item.Id) return null;
// Check for local/cached version first
const cachedUrl = preferLocal ? getLocalPath(item.Id) : null;
if (cachedUrl) {
// Downloaded track - instant return, no API call needed
return {
track: itemToTrack(item, cachedUrl, api, false), // false to avoid redundant cache check
sessionId: null,
mediaSource: null,
isTranscoding: false,
mediaInfo: null,
};
}
// Not downloaded - need to fetch stream URL from server
try {
const result = await getAudioStreamUrl(api, user.Id, item.Id);
if (!result) return null;
return {
track: itemToTrack(item, result.url, api, false),
sessionId: result.sessionId,
mediaSource: result.mediaSource,
isTranscoding: result.isTranscoding,
mediaInfo: {
mediaSource: result.mediaSource,
isTranscoding: result.isTranscoding,
},
};
} catch (error) {
console.warn(
`[MusicPlayer] Failed to prepare track ${item.Id}:`,
error,
);
// If server unreachable, check for cached version as fallback
const fallbackCached = getLocalPath(item.Id);
if (fallbackCached) {
return {
track: itemToTrack(item, fallbackCached, api, false),
sessionId: null,
mediaSource: null,
isTranscoding: false,
mediaInfo: null,
};
}
return null;
}
},
[api, user?.Id],
);
// Load remaining tracks in the background without blocking playback
const loadRemainingTracksInBackground = useCallback(
async (queue: BaseItemDto[], startIndex: number, preferLocal: boolean) => {
if (!api || !user?.Id) return;
const mediaInfoMap: Record<string, TrackMediaInfo> = {};
// Process tracks BEFORE the start index (insert at position 0, pushing current track forward)
const beforeTracks: Track[] = [];
for (let i = 0; i < startIndex; i++) {
const item = queue[i];
if (!item.Id) continue;
const prepared = await prepareTrack(item, preferLocal);
if (prepared) {
beforeTracks.push(prepared.track);
if (prepared.mediaInfo) {
mediaInfoMap[item.Id] = prepared.mediaInfo;
}
}
}
// Insert tracks before current track (they go at index 0)
if (beforeTracks.length > 0) {
await TrackPlayer.add(beforeTracks, 0);
// Update queue index since we inserted tracks before the current one
setState((prev) => ({
...prev,
queueIndex: beforeTracks.length,
trackMediaInfoMap: { ...prev.trackMediaInfoMap, ...mediaInfoMap },
}));
}
// Process tracks AFTER the start index (append to end)
for (let i = startIndex + 1; i < queue.length; i++) {
const item = queue[i];
if (!item.Id) continue;
const prepared = await prepareTrack(item, preferLocal);
if (prepared) {
await TrackPlayer.add(prepared.track); // Append to end
if (prepared.mediaInfo && item.Id) {
setState((prev) => ({
...prev,
trackMediaInfoMap: {
...prev.trackMediaInfoMap,
[item.Id!]: prepared.mediaInfo!,
},
}));
}
}
}
},
[api, user?.Id, prepareTrack],
);
const loadAndPlayQueue = useCallback(
async (queue: BaseItemDto[], startIndex: number) => {
if (!api || !user?.Id || queue.length === 0) return;
const trackToLoad = queue[startIndex];
const targetItem = queue[startIndex];
setState((prev) => ({
...prev,
isLoading: true,
loadingTrackId: trackToLoad?.Id ?? null,
loadingTrackId: targetItem?.Id ?? null,
}));
try {
// Get stream URLs for all tracks
const tracks: Track[] = [];
const mediaInfoMap: Record<string, TrackMediaInfo> = {};
let startTrackMediaSource: MediaSourceInfo | null = null;
let startTrackIsTranscoding = false;
const preferLocal = settings?.preferLocalAudio ?? true;
for (let i = 0; i < queue.length; i++) {
const item = queue[i];
if (!item.Id) continue;
// PHASE 1: Prepare and play the target track immediately
const targetTrackResult = await prepareTrack(targetItem, preferLocal);
// 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);
if (result) {
tracks.push(itemToTrack(item, result.url, api, preferLocal));
// Store media info for all tracks
mediaInfoMap[item.Id] = {
mediaSource: result.mediaSource,
isTranscoding: result.isTranscoding,
};
// Store first track's session ID
if (tracks.length === 1) {
setState((prev) => ({
...prev,
playSessionId: result.sessionId,
}));
}
// Store media source info for the starting track
if (i === startIndex) {
startTrackMediaSource = result.mediaSource;
startTrackIsTranscoding = result.isTranscoding;
}
} else if (cachedUrl) {
// Fallback to cached version if server is unreachable
console.log(
`[MusicPlayer] Using cached file (offline) for ${item.Name}: ${cachedUrl}`,
);
tracks.push(itemToTrack(item, cachedUrl, api, true));
}
}
if (tracks.length === 0) {
if (!targetTrackResult) {
setState((prev) => ({
...prev,
isLoading: false,
@@ -539,32 +632,40 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
return;
}
// Reset TrackPlayer and add new queue
// Reset and start playback immediately with just the target track
await TrackPlayer.reset();
await TrackPlayer.add(tracks);
await TrackPlayer.skip(startIndex);
await TrackPlayer.add(targetTrackResult.track);
await TrackPlayer.play();
const currentTrack = queue[startIndex];
// Update state for immediate playback
setState((prev) => ({
...prev,
queue,
originalQueue: queue,
queueIndex: startIndex,
currentTrack,
queueIndex: 0, // Target track is at index 0 in TrackPlayer initially
currentTrack: targetItem,
isLoading: false,
loadingTrackId: null,
isPlaying: true,
streamUrl: tracks[startIndex]?.url || null,
duration: currentTrack?.RunTimeTicks
? Math.floor(currentTrack.RunTimeTicks / 10000000)
streamUrl: targetTrackResult.track.url || null,
playSessionId: targetTrackResult.sessionId,
duration: targetItem?.RunTimeTicks
? Math.floor(targetItem.RunTimeTicks / 10000000)
: 0,
mediaSource: startTrackMediaSource,
isTranscoding: startTrackIsTranscoding,
trackMediaInfoMap: mediaInfoMap,
mediaSource: targetTrackResult.mediaSource,
isTranscoding: targetTrackResult.isTranscoding,
trackMediaInfoMap:
targetTrackResult.mediaInfo && targetItem.Id
? { [targetItem.Id]: targetTrackResult.mediaInfo }
: {},
}));
reportPlaybackStart(currentTrack, state.playSessionId);
reportPlaybackStart(targetItem, targetTrackResult.sessionId);
// PHASE 2: Load remaining tracks in background (non-blocking)
if (queue.length > 1) {
loadRemainingTracksInBackground(queue, startIndex, preferLocal);
}
} catch (error) {
console.error("[MusicPlayer] Error loading queue:", error);
setState((prev) => ({
@@ -574,7 +675,14 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
}));
}
},
[api, user?.Id, reportPlaybackStart, state.playSessionId],
[
api,
user?.Id,
reportPlaybackStart,
settings?.preferLocalAudio,
prepareTrack,
loadRemainingTracksInBackground,
],
);
const playTrack = useCallback(
@@ -615,9 +723,23 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
let finalQueue = queue;
let finalIndex = startIndex;
// When offline, filter to downloaded items only
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;
}
}
// Apply shuffle if enabled
if (state.shuffleEnabled) {
finalQueue = shuffleArray(queue, startIndex);
finalQueue = shuffleArray(finalQueue, finalIndex);
finalIndex = 0;
}
@@ -630,6 +752,7 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
state.shuffleEnabled,
reportPlaybackStopped,
loadAndPlayQueue,
isOffline,
],
);
@@ -1158,6 +1281,50 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
)
return;
// Check if the track exists in TrackPlayer queue (might not be loaded yet due to background loading)
const tpQueue = await TrackPlayer.getQueue();
const targetItem = state.queue[index];
if (index >= tpQueue.length) {
// Track not loaded yet - need to load it first
if (!targetItem) return;
setState((prev) => ({
...prev,
isLoading: true,
loadingTrackId: targetItem?.Id ?? null,
}));
const preferLocal = settings?.preferLocalAudio ?? true;
const prepared = await prepareTrack(targetItem, preferLocal);
if (!prepared) {
setState((prev) => ({
...prev,
isLoading: false,
loadingTrackId: null,
}));
return;
}
// Add the track at the correct position
await TrackPlayer.add(prepared.track, index);
setState((prev) => ({
...prev,
isLoading: false,
loadingTrackId: null,
...(prepared.mediaInfo && targetItem.Id
? {
trackMediaInfoMap: {
...prev.trackMediaInfoMap,
[targetItem.Id]: prepared.mediaInfo,
},
}
: {}),
}));
}
// Report stop for current track
if (state.currentTrack && state.playSessionId) {
reportPlaybackStopped(
state.currentTrack,
@@ -1169,14 +1336,13 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
await TrackPlayer.skip(index);
setState((prev) => {
const targetTrack = prev.queue[index];
const mediaInfo = targetTrack?.Id
? prev.trackMediaInfoMap[targetTrack.Id]
const mediaInfo = targetItem?.Id
? prev.trackMediaInfoMap[targetItem.Id]
: null;
return {
...prev,
queueIndex: index,
currentTrack: targetTrack,
currentTrack: targetItem,
mediaSource: mediaInfo?.mediaSource ?? null,
isTranscoding: mediaInfo?.isTranscoding ?? false,
};
@@ -1189,6 +1355,8 @@ export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
state.playSessionId,
state.progress,
reportPlaybackStopped,
settings?.preferLocalAudio,
prepareTrack,
],
);