mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-15 15:48:05 +00:00
1601 lines
45 KiB
TypeScript
1601 lines
45 KiB
TypeScript
import type { Api } from "@jellyfin/sdk";
|
|
import type {
|
|
BaseItemDto,
|
|
MediaSourceInfo,
|
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
|
import { getPlaystateApi } from "@jellyfin/sdk/lib/utils/api";
|
|
import { useAtomValue } from "jotai";
|
|
import React, {
|
|
createContext,
|
|
type ReactNode,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import TrackPlayer, {
|
|
Capability,
|
|
type Progress,
|
|
RepeatMode as TPRepeatMode,
|
|
type Track,
|
|
} from "react-native-track-player";
|
|
import {
|
|
downloadTrack,
|
|
getLocalPath,
|
|
initAudioStorage,
|
|
isDownloading,
|
|
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";
|
|
|
|
// Storage keys
|
|
const STORAGE_KEYS = {
|
|
QUEUE: "music_player_queue",
|
|
QUEUE_INDEX: "music_player_queue_index",
|
|
REPEAT_MODE: "music_player_repeat_mode",
|
|
SHUFFLE_ENABLED: "music_player_shuffle_enabled",
|
|
CURRENT_PROGRESS: "music_player_progress",
|
|
} as const;
|
|
|
|
export type RepeatMode = "off" | "all" | "one";
|
|
|
|
interface TrackMediaInfo {
|
|
mediaSource: MediaSourceInfo | null;
|
|
isTranscoding: boolean;
|
|
}
|
|
|
|
interface PreparedTrack {
|
|
track: Track;
|
|
sessionId: string | null;
|
|
mediaSource: MediaSourceInfo | null;
|
|
isTranscoding: boolean;
|
|
mediaInfo: TrackMediaInfo | null;
|
|
}
|
|
|
|
interface MusicPlayerState {
|
|
currentTrack: BaseItemDto | null;
|
|
queue: BaseItemDto[];
|
|
originalQueue: BaseItemDto[]; // Original order before shuffle
|
|
queueIndex: number;
|
|
isPlaying: boolean;
|
|
isLoading: boolean;
|
|
loadingTrackId: string | null; // Track ID being loaded
|
|
progress: number;
|
|
duration: number;
|
|
streamUrl: string | null;
|
|
playSessionId: string | null;
|
|
repeatMode: RepeatMode;
|
|
shuffleEnabled: boolean;
|
|
mediaSource: MediaSourceInfo | null;
|
|
isTranscoding: boolean;
|
|
trackMediaInfoMap: Record<string, TrackMediaInfo>;
|
|
}
|
|
|
|
interface MusicPlayerContextType extends MusicPlayerState {
|
|
// Playback control
|
|
playTrack: (track: BaseItemDto, queue?: BaseItemDto[]) => void;
|
|
playQueue: (queue: BaseItemDto[], startIndex?: number) => void;
|
|
playAlbum: (albumId: string, startIndex?: number) => void;
|
|
playPlaylist: (playlistId: string, startIndex?: number) => void;
|
|
pause: () => void;
|
|
resume: () => void;
|
|
togglePlayPause: () => void;
|
|
next: () => void;
|
|
previous: () => void;
|
|
seek: (position: number) => void;
|
|
stop: () => void;
|
|
|
|
// Queue management
|
|
addToQueue: (tracks: BaseItemDto | BaseItemDto[]) => void;
|
|
playNext: (tracks: BaseItemDto | BaseItemDto[]) => void;
|
|
removeFromQueue: (index: number) => void;
|
|
moveInQueue: (fromIndex: number, toIndex: number) => void;
|
|
reorderQueue: (newQueue: BaseItemDto[]) => void;
|
|
clearQueue: () => void;
|
|
jumpToIndex: (index: number) => void;
|
|
|
|
// Modes
|
|
setRepeatMode: (mode: RepeatMode) => void;
|
|
toggleShuffle: () => void;
|
|
|
|
// Internal setters (for playback engine)
|
|
setProgress: (progress: number) => void;
|
|
setDuration: (duration: number) => void;
|
|
setIsPlaying: (isPlaying: boolean) => void;
|
|
reportProgress: () => void;
|
|
onTrackEnd: () => void;
|
|
syncFromTrackPlayer: () => void;
|
|
|
|
// Audio caching
|
|
triggerLookahead: () => void;
|
|
}
|
|
|
|
const MusicPlayerContext = createContext<MusicPlayerContextType | undefined>(
|
|
undefined,
|
|
);
|
|
|
|
export const useMusicPlayer = () => {
|
|
const context = useContext(MusicPlayerContext);
|
|
if (!context) {
|
|
throw new Error("useMusicPlayer must be used within MusicPlayerProvider");
|
|
}
|
|
return context;
|
|
};
|
|
|
|
interface MusicPlayerProviderProps {
|
|
children: ReactNode;
|
|
}
|
|
|
|
// Persistence helpers
|
|
const saveQueueToStorage = (queue: BaseItemDto[], queueIndex: number) => {
|
|
try {
|
|
storage.set(STORAGE_KEYS.QUEUE, JSON.stringify(queue));
|
|
storage.set(STORAGE_KEYS.QUEUE_INDEX, queueIndex.toString());
|
|
} catch {
|
|
// Silently fail
|
|
}
|
|
};
|
|
|
|
const loadQueueFromStorage = (): {
|
|
queue: BaseItemDto[];
|
|
queueIndex: number;
|
|
} | null => {
|
|
try {
|
|
const queueJson = storage.getString(STORAGE_KEYS.QUEUE);
|
|
const indexStr = storage.getString(STORAGE_KEYS.QUEUE_INDEX);
|
|
|
|
if (queueJson && indexStr) {
|
|
const queue = JSON.parse(queueJson) as BaseItemDto[];
|
|
const queueIndex = parseInt(indexStr, 10);
|
|
if (queue.length > 0 && queueIndex >= 0 && queueIndex < queue.length) {
|
|
return { queue, queueIndex };
|
|
}
|
|
}
|
|
} catch {
|
|
// Silently fail
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const loadRepeatMode = (): RepeatMode => {
|
|
try {
|
|
const mode = storage.getString(STORAGE_KEYS.REPEAT_MODE);
|
|
if (mode === "off" || mode === "all" || mode === "one") {
|
|
return mode;
|
|
}
|
|
} catch {
|
|
// Silently fail
|
|
}
|
|
return "off";
|
|
};
|
|
|
|
const loadShuffleEnabled = (): boolean => {
|
|
try {
|
|
return storage.getBoolean(STORAGE_KEYS.SHUFFLE_ENABLED) ?? false;
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const saveProgress = (progress: number) => {
|
|
try {
|
|
storage.set(STORAGE_KEYS.CURRENT_PROGRESS, progress.toString());
|
|
} catch {
|
|
// Silently fail
|
|
}
|
|
};
|
|
|
|
const loadProgress = (): number => {
|
|
try {
|
|
const progressStr = storage.getString(STORAGE_KEYS.CURRENT_PROGRESS);
|
|
if (progressStr) {
|
|
return parseFloat(progressStr);
|
|
}
|
|
} catch {
|
|
// Silently fail
|
|
}
|
|
return 0;
|
|
};
|
|
|
|
// Shuffle array using Fisher-Yates
|
|
const shuffleArray = <T,>(array: T[], currentIndex: number): T[] => {
|
|
const result = [...array];
|
|
const currentItem = result[currentIndex];
|
|
|
|
// Remove current item
|
|
result.splice(currentIndex, 1);
|
|
|
|
// Shuffle remaining
|
|
for (let i = result.length - 1; i > 0; i--) {
|
|
const j = Math.floor(Math.random() * (i + 1));
|
|
[result[i], result[j]] = [result[j], result[i]];
|
|
}
|
|
|
|
// Put current item at the beginning
|
|
result.unshift(currentItem);
|
|
|
|
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,
|
|
url: string,
|
|
api: Api,
|
|
preferLocalAudio = true,
|
|
): Track => {
|
|
const albumId = item.AlbumId || item.ParentId;
|
|
const artworkId = albumId || item.Id;
|
|
const artwork = artworkId
|
|
? `${api.basePath}/Items/${artworkId}/Images/Primary?maxHeight=512&maxWidth=512&quality=90`
|
|
: undefined;
|
|
|
|
// Check if track is cached locally (permanent downloads take precedence)
|
|
// getLocalPath returns full file:// URI if cached, null otherwise
|
|
const cachedUrl = preferLocalAudio ? getLocalPath(item.Id) : null;
|
|
const finalUrl = cachedUrl || url;
|
|
|
|
if (cachedUrl) {
|
|
console.log(
|
|
`[MusicPlayer] Using cached file for ${item.Name}: ${cachedUrl}`,
|
|
);
|
|
}
|
|
|
|
return {
|
|
id: item.Id || "",
|
|
url: finalUrl,
|
|
title: item.Name || "Unknown",
|
|
artist: item.Artists?.join(", ") || item.AlbumArtist || "Unknown Artist",
|
|
album: item.Album || undefined,
|
|
artwork,
|
|
duration: item.RunTimeTicks ? item.RunTimeTicks / 10000000 : undefined,
|
|
};
|
|
};
|
|
|
|
export const MusicPlayerProvider: React.FC<MusicPlayerProviderProps> = ({
|
|
children,
|
|
}) => {
|
|
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);
|
|
|
|
const [state, setState] = useState<MusicPlayerState>({
|
|
currentTrack: null,
|
|
queue: [],
|
|
originalQueue: [],
|
|
queueIndex: 0,
|
|
isPlaying: false,
|
|
isLoading: false,
|
|
loadingTrackId: null,
|
|
progress: 0,
|
|
duration: 0,
|
|
streamUrl: null,
|
|
playSessionId: null,
|
|
repeatMode: loadRepeatMode(),
|
|
shuffleEnabled: loadShuffleEnabled(),
|
|
mediaSource: null,
|
|
isTranscoding: false,
|
|
trackMediaInfoMap: {},
|
|
});
|
|
|
|
const lastReportRef = useRef<number>(0);
|
|
|
|
// Setup TrackPlayer and AudioStorage
|
|
useEffect(() => {
|
|
const setupPlayer = async () => {
|
|
if (playerSetupRef.current) return;
|
|
|
|
try {
|
|
// Initialize audio storage for caching
|
|
await initAudioStorage();
|
|
|
|
await TrackPlayer.setupPlayer({
|
|
minBuffer: 120, // Minimum 2 minutes buffer for network resilience
|
|
maxBuffer: 240, // Maximum 4 minutes buffer
|
|
playBuffer: 5, // Start playback after 5 seconds buffered
|
|
backBuffer: 30, // Keep 30 seconds behind for seeking
|
|
});
|
|
await TrackPlayer.updateOptions({
|
|
capabilities: [
|
|
Capability.Play,
|
|
Capability.Pause,
|
|
Capability.SkipToNext,
|
|
Capability.SkipToPrevious,
|
|
Capability.SeekTo,
|
|
Capability.Stop,
|
|
],
|
|
compactCapabilities: [
|
|
Capability.Play,
|
|
Capability.Pause,
|
|
Capability.SkipToNext,
|
|
Capability.SkipToPrevious,
|
|
],
|
|
});
|
|
playerSetupRef.current = true;
|
|
} catch (_error) {
|
|
// Player might already be set up
|
|
playerSetupRef.current = true;
|
|
}
|
|
};
|
|
|
|
setupPlayer();
|
|
}, []);
|
|
|
|
// Update audio cache size when settings change
|
|
useEffect(() => {
|
|
if (settings?.audioMaxCacheSizeMB) {
|
|
setMaxCacheSizeMB(settings.audioMaxCacheSizeMB);
|
|
}
|
|
}, [settings?.audioMaxCacheSizeMB]);
|
|
|
|
// Sync repeat mode to TrackPlayer
|
|
useEffect(() => {
|
|
const syncRepeatMode = async () => {
|
|
if (!playerSetupRef.current) return;
|
|
|
|
let tpRepeatMode: TPRepeatMode;
|
|
switch (state.repeatMode) {
|
|
case "one":
|
|
tpRepeatMode = TPRepeatMode.Track;
|
|
break;
|
|
case "all":
|
|
tpRepeatMode = TPRepeatMode.Queue;
|
|
break;
|
|
default:
|
|
tpRepeatMode = TPRepeatMode.Off;
|
|
}
|
|
await TrackPlayer.setRepeatMode(tpRepeatMode);
|
|
};
|
|
|
|
syncRepeatMode();
|
|
}, [state.repeatMode]);
|
|
|
|
// Restore queue on mount (when api is available)
|
|
useEffect(() => {
|
|
if (!api || !user?.Id || initializedRef.current) return;
|
|
initializedRef.current = true;
|
|
|
|
const saved = loadQueueFromStorage();
|
|
if (saved && saved.queue.length > 0) {
|
|
const currentTrack = saved.queue[saved.queueIndex];
|
|
const savedProgress = loadProgress();
|
|
|
|
setState((prev) => ({
|
|
...prev,
|
|
queue: saved.queue,
|
|
originalQueue: saved.queue,
|
|
queueIndex: saved.queueIndex,
|
|
currentTrack,
|
|
progress: savedProgress,
|
|
duration: currentTrack?.RunTimeTicks
|
|
? Math.floor(currentTrack.RunTimeTicks / 10000000)
|
|
: 0,
|
|
isPlaying: false, // Don't auto-play on restore
|
|
}));
|
|
}
|
|
}, [api, user?.Id]);
|
|
|
|
// Save queue whenever it changes
|
|
useEffect(() => {
|
|
if (state.queue.length > 0) {
|
|
saveQueueToStorage(state.queue, state.queueIndex);
|
|
}
|
|
}, [state.queue, state.queueIndex]);
|
|
|
|
// Save progress periodically
|
|
useEffect(() => {
|
|
if (state.progress > 0 && state.currentTrack) {
|
|
saveProgress(state.progress);
|
|
}
|
|
}, [state.progress, state.currentTrack]);
|
|
|
|
const reportPlaybackStart = useCallback(
|
|
async (track: BaseItemDto, sessionId: string | null) => {
|
|
if (!api || !user?.Id || !track.Id) return;
|
|
|
|
try {
|
|
await getPlaystateApi(api).reportPlaybackStart({
|
|
playbackStartInfo: {
|
|
ItemId: track.Id,
|
|
PlaySessionId: sessionId || undefined,
|
|
CanSeek: true,
|
|
IsPaused: false,
|
|
IsMuted: false,
|
|
VolumeLevel: 100,
|
|
PlayMethod: "DirectStream",
|
|
},
|
|
});
|
|
} catch {
|
|
// Silently fail
|
|
}
|
|
},
|
|
[api, user?.Id],
|
|
);
|
|
|
|
const reportPlaybackProgress = useCallback(async () => {
|
|
if (!api || !user?.Id || !state.currentTrack?.Id) return;
|
|
|
|
const now = Date.now();
|
|
if (now - lastReportRef.current < 10000) return;
|
|
lastReportRef.current = now;
|
|
|
|
try {
|
|
await getPlaystateApi(api).reportPlaybackProgress({
|
|
playbackProgressInfo: {
|
|
ItemId: state.currentTrack.Id,
|
|
PlaySessionId: state.playSessionId || undefined,
|
|
PositionTicks: Math.floor(state.progress * 10000000),
|
|
CanSeek: true,
|
|
IsPaused: !state.isPlaying,
|
|
IsMuted: false,
|
|
VolumeLevel: 100,
|
|
PlayMethod: "DirectStream",
|
|
},
|
|
});
|
|
} catch {
|
|
// Silently fail
|
|
}
|
|
}, [
|
|
api,
|
|
user?.Id,
|
|
state.currentTrack?.Id,
|
|
state.playSessionId,
|
|
state.progress,
|
|
state.isPlaying,
|
|
]);
|
|
|
|
const reportPlaybackStopped = useCallback(
|
|
async (
|
|
track: BaseItemDto,
|
|
positionTicks: number,
|
|
sessionId: string | null,
|
|
) => {
|
|
if (!api || !user?.Id || !track.Id) return;
|
|
|
|
try {
|
|
await getPlaystateApi(api).reportPlaybackStopped({
|
|
playbackStopInfo: {
|
|
ItemId: track.Id,
|
|
PlaySessionId: sessionId || undefined,
|
|
PositionTicks: Math.floor(positionTicks),
|
|
},
|
|
});
|
|
} catch {
|
|
// Silently fail
|
|
}
|
|
},
|
|
[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> = {};
|
|
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;
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
// 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!,
|
|
},
|
|
}));
|
|
}
|
|
} 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],
|
|
);
|
|
|
|
const loadAndPlayQueue = useCallback(
|
|
async (queue: BaseItemDto[], startIndex: number) => {
|
|
if (!api || !user?.Id || queue.length === 0) return;
|
|
|
|
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,
|
|
loadingTrackId: targetItem?.Id ?? null,
|
|
}));
|
|
|
|
try {
|
|
// PHASE 1: Prepare and play the target track immediately
|
|
const targetTrackResult = await prepareTrack(targetItem, preferLocal);
|
|
|
|
if (!targetTrackResult) {
|
|
setState((prev) => ({
|
|
...prev,
|
|
isLoading: false,
|
|
loadingTrackId: null,
|
|
}));
|
|
return;
|
|
}
|
|
|
|
// Reset and start playback immediately with just the target track
|
|
await TrackPlayer.reset();
|
|
await TrackPlayer.add(targetTrackResult.track);
|
|
await TrackPlayer.play();
|
|
|
|
// Update state for immediate playback
|
|
setState((prev) => ({
|
|
...prev,
|
|
queue: finalQueue,
|
|
originalQueue: finalQueue,
|
|
queueIndex: 0, // Target track is at index 0 in TrackPlayer initially
|
|
currentTrack: targetItem,
|
|
isLoading: false,
|
|
loadingTrackId: null,
|
|
isPlaying: true,
|
|
streamUrl: targetTrackResult.track.url || null,
|
|
playSessionId: targetTrackResult.sessionId,
|
|
duration: targetItem?.RunTimeTicks
|
|
? Math.floor(targetItem.RunTimeTicks / 10000000)
|
|
: 0,
|
|
mediaSource: targetTrackResult.mediaSource,
|
|
isTranscoding: targetTrackResult.isTranscoding,
|
|
trackMediaInfoMap:
|
|
targetTrackResult.mediaInfo && targetItem.Id
|
|
? { [targetItem.Id]: targetTrackResult.mediaInfo }
|
|
: {},
|
|
}));
|
|
|
|
reportPlaybackStart(targetItem, targetTrackResult.sessionId);
|
|
|
|
// PHASE 2: Load remaining tracks in background (non-blocking)
|
|
if (finalQueue.length > 1) {
|
|
loadRemainingTracksInBackground(finalQueue, finalIndex, preferLocal);
|
|
}
|
|
} catch (error) {
|
|
console.error("[MusicPlayer] Error loading queue:", error);
|
|
setState((prev) => ({
|
|
...prev,
|
|
isLoading: false,
|
|
loadingTrackId: null,
|
|
}));
|
|
}
|
|
},
|
|
[
|
|
api,
|
|
user?.Id,
|
|
reportPlaybackStart,
|
|
settings?.preferLocalAudio,
|
|
prepareTrack,
|
|
loadRemainingTracksInBackground,
|
|
isOffline,
|
|
],
|
|
);
|
|
|
|
const playTrack = useCallback(
|
|
(track: BaseItemDto, queue?: BaseItemDto[]) => {
|
|
if (state.currentTrack && state.playSessionId) {
|
|
reportPlaybackStopped(
|
|
state.currentTrack,
|
|
state.progress * 10000000,
|
|
state.playSessionId,
|
|
);
|
|
}
|
|
|
|
const newQueue = queue || [track];
|
|
const queueIndex = newQueue.findIndex((t) => t.Id === track.Id);
|
|
loadAndPlayQueue(newQueue, queueIndex >= 0 ? queueIndex : 0);
|
|
},
|
|
[
|
|
state.currentTrack,
|
|
state.playSessionId,
|
|
state.progress,
|
|
reportPlaybackStopped,
|
|
loadAndPlayQueue,
|
|
],
|
|
);
|
|
|
|
const playQueue = useCallback(
|
|
(queue: BaseItemDto[], startIndex = 0) => {
|
|
if (queue.length === 0) return;
|
|
|
|
if (state.currentTrack && state.playSessionId) {
|
|
reportPlaybackStopped(
|
|
state.currentTrack,
|
|
state.progress * 10000000,
|
|
state.playSessionId,
|
|
);
|
|
}
|
|
|
|
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(finalQueue, finalIndex);
|
|
finalIndex = 0;
|
|
}
|
|
|
|
loadAndPlayQueue(finalQueue, finalIndex);
|
|
},
|
|
[
|
|
state.currentTrack,
|
|
state.playSessionId,
|
|
state.progress,
|
|
state.shuffleEnabled,
|
|
reportPlaybackStopped,
|
|
loadAndPlayQueue,
|
|
isOffline,
|
|
],
|
|
);
|
|
|
|
const playAlbum = useCallback(
|
|
async (albumId: string, startIndex = 0) => {
|
|
if (!api || !user?.Id) return;
|
|
|
|
try {
|
|
const { getItemsApi } = await import("@jellyfin/sdk/lib/utils/api");
|
|
const response = await getItemsApi(api).getItems({
|
|
userId: user.Id,
|
|
parentId: albumId,
|
|
sortBy: ["IndexNumber"],
|
|
sortOrder: ["Ascending"],
|
|
});
|
|
|
|
const tracks = response.data.Items || [];
|
|
if (tracks.length > 0) {
|
|
playQueue(tracks, startIndex);
|
|
}
|
|
} catch {
|
|
// Silently fail
|
|
}
|
|
},
|
|
[api, user?.Id, playQueue],
|
|
);
|
|
|
|
const playPlaylist = useCallback(
|
|
async (playlistId: string, startIndex = 0) => {
|
|
if (!api || !user?.Id) return;
|
|
|
|
try {
|
|
const { getItemsApi } = await import("@jellyfin/sdk/lib/utils/api");
|
|
const response = await getItemsApi(api).getItems({
|
|
userId: user.Id,
|
|
parentId: playlistId,
|
|
sortBy: ["SortName"],
|
|
sortOrder: ["Ascending"],
|
|
});
|
|
|
|
const tracks = response.data.Items || [];
|
|
if (tracks.length > 0) {
|
|
playQueue(tracks, startIndex);
|
|
}
|
|
} catch {
|
|
// Silently fail
|
|
}
|
|
},
|
|
[api, user?.Id, playQueue],
|
|
);
|
|
|
|
const pause = useCallback(async () => {
|
|
await TrackPlayer.pause();
|
|
setState((prev) => ({ ...prev, isPlaying: false }));
|
|
}, []);
|
|
|
|
const resume = useCallback(async () => {
|
|
if (!state.streamUrl && state.currentTrack && api && user?.Id) {
|
|
// Need to load the track first (e.g., after app restart)
|
|
const result = await getAudioStreamUrl(
|
|
api,
|
|
user.Id,
|
|
state.currentTrack.Id!,
|
|
);
|
|
if (result) {
|
|
const preferLocal = settings?.preferLocalAudio ?? true;
|
|
await TrackPlayer.reset();
|
|
await TrackPlayer.add(
|
|
itemToTrack(state.currentTrack, result.url, api, preferLocal),
|
|
);
|
|
await TrackPlayer.seekTo(state.progress);
|
|
await TrackPlayer.play();
|
|
setState((prev) => ({
|
|
...prev,
|
|
streamUrl: result.url,
|
|
playSessionId: result.sessionId,
|
|
isPlaying: true,
|
|
}));
|
|
}
|
|
} else {
|
|
await TrackPlayer.play();
|
|
setState((prev) => ({ ...prev, isPlaying: true }));
|
|
}
|
|
}, [
|
|
api,
|
|
user?.Id,
|
|
state.streamUrl,
|
|
state.currentTrack,
|
|
state.progress,
|
|
settings?.preferLocalAudio,
|
|
]);
|
|
|
|
const togglePlayPause = useCallback(async () => {
|
|
if (state.isPlaying) {
|
|
await pause();
|
|
} else {
|
|
await resume();
|
|
}
|
|
}, [state.isPlaying, pause, resume]);
|
|
|
|
const next = useCallback(async () => {
|
|
const currentIndex = await TrackPlayer.getActiveTrackIndex();
|
|
const queueLength = (await TrackPlayer.getQueue()).length;
|
|
|
|
if (currentIndex !== undefined && currentIndex < queueLength - 1) {
|
|
if (state.currentTrack && state.playSessionId) {
|
|
reportPlaybackStopped(
|
|
state.currentTrack,
|
|
state.progress * 10000000,
|
|
state.playSessionId,
|
|
);
|
|
}
|
|
await TrackPlayer.skipToNext();
|
|
const newIndex = currentIndex + 1;
|
|
setState((prev) => {
|
|
const nextTrack = prev.queue[newIndex];
|
|
const mediaInfo = nextTrack?.Id
|
|
? prev.trackMediaInfoMap[nextTrack.Id]
|
|
: null;
|
|
return {
|
|
...prev,
|
|
queueIndex: newIndex,
|
|
currentTrack: nextTrack,
|
|
mediaSource: mediaInfo?.mediaSource ?? null,
|
|
isTranscoding: mediaInfo?.isTranscoding ?? false,
|
|
};
|
|
});
|
|
} else if (state.repeatMode === "all" && state.queue.length > 0) {
|
|
if (state.currentTrack && state.playSessionId) {
|
|
reportPlaybackStopped(
|
|
state.currentTrack,
|
|
state.progress * 10000000,
|
|
state.playSessionId,
|
|
);
|
|
}
|
|
await TrackPlayer.skip(0);
|
|
setState((prev) => {
|
|
const firstTrack = prev.queue[0];
|
|
const mediaInfo = firstTrack?.Id
|
|
? prev.trackMediaInfoMap[firstTrack.Id]
|
|
: null;
|
|
return {
|
|
...prev,
|
|
queueIndex: 0,
|
|
currentTrack: firstTrack,
|
|
mediaSource: mediaInfo?.mediaSource ?? null,
|
|
isTranscoding: mediaInfo?.isTranscoding ?? false,
|
|
};
|
|
});
|
|
}
|
|
}, [
|
|
state.queue,
|
|
state.currentTrack,
|
|
state.playSessionId,
|
|
state.progress,
|
|
state.repeatMode,
|
|
reportPlaybackStopped,
|
|
]);
|
|
|
|
const previous = useCallback(async () => {
|
|
const position = await TrackPlayer.getProgress().then(
|
|
(p: Progress) => p.position,
|
|
);
|
|
|
|
if (position > 3) {
|
|
await TrackPlayer.seekTo(0);
|
|
setState((prev) => ({ ...prev, progress: 0 }));
|
|
return;
|
|
}
|
|
|
|
const currentIndex = await TrackPlayer.getActiveTrackIndex();
|
|
|
|
if (currentIndex !== undefined && currentIndex > 0) {
|
|
if (state.currentTrack && state.playSessionId) {
|
|
reportPlaybackStopped(
|
|
state.currentTrack,
|
|
state.progress * 10000000,
|
|
state.playSessionId,
|
|
);
|
|
}
|
|
await TrackPlayer.skipToPrevious();
|
|
const newIndex = currentIndex - 1;
|
|
setState((prev) => {
|
|
const prevTrack = prev.queue[newIndex];
|
|
const mediaInfo = prevTrack?.Id
|
|
? prev.trackMediaInfoMap[prevTrack.Id]
|
|
: null;
|
|
return {
|
|
...prev,
|
|
queueIndex: newIndex,
|
|
currentTrack: prevTrack,
|
|
mediaSource: mediaInfo?.mediaSource ?? null,
|
|
isTranscoding: mediaInfo?.isTranscoding ?? false,
|
|
};
|
|
});
|
|
} else if (state.repeatMode === "all" && state.queue.length > 0) {
|
|
const lastIndex = state.queue.length - 1;
|
|
if (state.currentTrack && state.playSessionId) {
|
|
reportPlaybackStopped(
|
|
state.currentTrack,
|
|
state.progress * 10000000,
|
|
state.playSessionId,
|
|
);
|
|
}
|
|
await TrackPlayer.skip(lastIndex);
|
|
setState((prev) => {
|
|
const lastTrack = prev.queue[lastIndex];
|
|
const mediaInfo = lastTrack?.Id
|
|
? prev.trackMediaInfoMap[lastTrack.Id]
|
|
: null;
|
|
return {
|
|
...prev,
|
|
queueIndex: lastIndex,
|
|
currentTrack: lastTrack,
|
|
mediaSource: mediaInfo?.mediaSource ?? null,
|
|
isTranscoding: mediaInfo?.isTranscoding ?? false,
|
|
};
|
|
});
|
|
}
|
|
}, [
|
|
state.queue,
|
|
state.currentTrack,
|
|
state.playSessionId,
|
|
state.progress,
|
|
state.repeatMode,
|
|
reportPlaybackStopped,
|
|
]);
|
|
|
|
const seek = useCallback(async (position: number) => {
|
|
await TrackPlayer.seekTo(position);
|
|
setState((prev) => ({ ...prev, progress: position }));
|
|
}, []);
|
|
|
|
const stop = useCallback(async () => {
|
|
if (state.currentTrack && state.playSessionId) {
|
|
reportPlaybackStopped(
|
|
state.currentTrack,
|
|
state.progress * 10000000,
|
|
state.playSessionId,
|
|
);
|
|
}
|
|
|
|
await TrackPlayer.reset();
|
|
|
|
// Clear storage
|
|
try {
|
|
storage.remove(STORAGE_KEYS.QUEUE);
|
|
storage.remove(STORAGE_KEYS.QUEUE_INDEX);
|
|
storage.remove(STORAGE_KEYS.CURRENT_PROGRESS);
|
|
} catch {
|
|
// Silently fail
|
|
}
|
|
|
|
setState({
|
|
currentTrack: null,
|
|
queue: [],
|
|
originalQueue: [],
|
|
queueIndex: 0,
|
|
isPlaying: false,
|
|
isLoading: false,
|
|
loadingTrackId: null,
|
|
progress: 0,
|
|
duration: 0,
|
|
streamUrl: null,
|
|
playSessionId: null,
|
|
repeatMode: state.repeatMode,
|
|
shuffleEnabled: state.shuffleEnabled,
|
|
mediaSource: null,
|
|
isTranscoding: false,
|
|
trackMediaInfoMap: {},
|
|
});
|
|
}, [
|
|
state.currentTrack,
|
|
state.playSessionId,
|
|
state.progress,
|
|
state.repeatMode,
|
|
state.shuffleEnabled,
|
|
reportPlaybackStopped,
|
|
]);
|
|
|
|
// Queue management
|
|
const addToQueue = useCallback(
|
|
async (tracks: BaseItemDto | BaseItemDto[]) => {
|
|
if (!api || !user?.Id) return;
|
|
|
|
const tracksArray = Array.isArray(tracks) ? tracks : [tracks];
|
|
const preferLocal = settings?.preferLocalAudio ?? true;
|
|
|
|
// Add to TrackPlayer queue
|
|
for (const item of tracksArray) {
|
|
if (!item.Id) continue;
|
|
const cachedUrl = getLocalPath(item.Id);
|
|
const result = await getAudioStreamUrl(api, user.Id, item.Id);
|
|
if (result) {
|
|
await TrackPlayer.add(
|
|
itemToTrack(item, result.url, api, preferLocal),
|
|
);
|
|
} else if (cachedUrl) {
|
|
console.log(
|
|
`[MusicPlayer] Using cached file (offline) for ${item.Name}: ${cachedUrl}`,
|
|
);
|
|
await TrackPlayer.add(itemToTrack(item, cachedUrl, api, true));
|
|
}
|
|
}
|
|
|
|
setState((prev) => ({
|
|
...prev,
|
|
queue: [...prev.queue, ...tracksArray],
|
|
originalQueue: [...prev.originalQueue, ...tracksArray],
|
|
}));
|
|
},
|
|
[api, user?.Id, settings?.preferLocalAudio],
|
|
);
|
|
|
|
const playNext = useCallback(
|
|
async (tracks: BaseItemDto | BaseItemDto[]) => {
|
|
if (!api || !user?.Id) return;
|
|
|
|
const tracksArray = Array.isArray(tracks) ? tracks : [tracks];
|
|
const currentIndex = await TrackPlayer.getActiveTrackIndex();
|
|
const insertIndex = (currentIndex ?? -1) + 1;
|
|
const preferLocal = settings?.preferLocalAudio ?? true;
|
|
|
|
// Add to TrackPlayer queue after current track
|
|
for (let i = tracksArray.length - 1; i >= 0; i--) {
|
|
const item = tracksArray[i];
|
|
if (!item.Id) continue;
|
|
const cachedUrl = getLocalPath(item.Id);
|
|
const result = await getAudioStreamUrl(api, user.Id, item.Id);
|
|
if (result) {
|
|
await TrackPlayer.add(
|
|
itemToTrack(item, result.url, api, preferLocal),
|
|
insertIndex,
|
|
);
|
|
} else if (cachedUrl) {
|
|
console.log(
|
|
`[MusicPlayer] Using cached file (offline) for ${item.Name}: ${cachedUrl}`,
|
|
);
|
|
await TrackPlayer.add(
|
|
itemToTrack(item, cachedUrl, api, true),
|
|
insertIndex,
|
|
);
|
|
}
|
|
}
|
|
|
|
setState((prev) => {
|
|
const stateInsertIndex = prev.queueIndex + 1;
|
|
const newQueue = [...prev.queue];
|
|
const newOriginalQueue = [...prev.originalQueue];
|
|
|
|
newQueue.splice(stateInsertIndex, 0, ...tracksArray);
|
|
newOriginalQueue.splice(stateInsertIndex, 0, ...tracksArray);
|
|
|
|
return {
|
|
...prev,
|
|
queue: newQueue,
|
|
originalQueue: newOriginalQueue,
|
|
};
|
|
});
|
|
},
|
|
[api, user?.Id, settings?.preferLocalAudio],
|
|
);
|
|
|
|
const removeFromQueue = useCallback(async (index: number) => {
|
|
const queueLength = (await TrackPlayer.getQueue()).length;
|
|
const currentIndex = await TrackPlayer.getActiveTrackIndex();
|
|
|
|
if (index < 0 || index >= queueLength) return;
|
|
if (index === currentIndex) return; // Can't remove currently playing
|
|
|
|
await TrackPlayer.remove(index);
|
|
|
|
setState((prev) => {
|
|
if (index < 0 || index >= prev.queue.length) return prev;
|
|
if (index === prev.queueIndex) return prev;
|
|
|
|
const newQueue = [...prev.queue];
|
|
const removedTrack = newQueue.splice(index, 1)[0];
|
|
|
|
const newOriginalQueue = prev.originalQueue.filter(
|
|
(t) => t.Id !== removedTrack.Id,
|
|
);
|
|
|
|
const newIndex =
|
|
index < prev.queueIndex ? prev.queueIndex - 1 : prev.queueIndex;
|
|
|
|
return {
|
|
...prev,
|
|
queue: newQueue,
|
|
originalQueue: newOriginalQueue,
|
|
queueIndex: newIndex,
|
|
};
|
|
});
|
|
}, []);
|
|
|
|
const moveInQueue = useCallback(
|
|
async (fromIndex: number, toIndex: number) => {
|
|
const queue = await TrackPlayer.getQueue();
|
|
if (
|
|
fromIndex < 0 ||
|
|
fromIndex >= queue.length ||
|
|
toIndex < 0 ||
|
|
toIndex >= queue.length ||
|
|
fromIndex === toIndex
|
|
) {
|
|
return;
|
|
}
|
|
|
|
await TrackPlayer.move(fromIndex, toIndex);
|
|
|
|
setState((prev) => {
|
|
const newQueue = [...prev.queue];
|
|
const [movedItem] = newQueue.splice(fromIndex, 1);
|
|
newQueue.splice(toIndex, 0, movedItem);
|
|
|
|
let newIndex = prev.queueIndex;
|
|
if (fromIndex === prev.queueIndex) {
|
|
newIndex = toIndex;
|
|
} else if (fromIndex < prev.queueIndex && toIndex >= prev.queueIndex) {
|
|
newIndex = prev.queueIndex - 1;
|
|
} else if (fromIndex > prev.queueIndex && toIndex <= prev.queueIndex) {
|
|
newIndex = prev.queueIndex + 1;
|
|
}
|
|
|
|
return {
|
|
...prev,
|
|
queue: newQueue,
|
|
queueIndex: newIndex,
|
|
};
|
|
});
|
|
},
|
|
[],
|
|
);
|
|
|
|
// Reorder queue with a new array (used by drag-to-reorder UI)
|
|
const reorderQueue = useCallback(
|
|
async (newQueue: BaseItemDto[]) => {
|
|
// Find where the current track ended up in the new order
|
|
const currentTrackId = state.currentTrack?.Id;
|
|
const newIndex = currentTrackId
|
|
? newQueue.findIndex((t) => t.Id === currentTrackId)
|
|
: 0;
|
|
|
|
// Build the reordering operations for TrackPlayer
|
|
// We need to match TrackPlayer's queue to the new order
|
|
const tpQueue = await TrackPlayer.getQueue();
|
|
|
|
// Create a map of trackId -> current TrackPlayer index
|
|
const currentPositions = new Map<string, number>();
|
|
tpQueue.forEach((track, idx) => {
|
|
currentPositions.set(track.id, idx);
|
|
});
|
|
|
|
// Move tracks one by one to match the new order
|
|
// Work backwards to avoid index shifting issues
|
|
for (let targetIdx = newQueue.length - 1; targetIdx >= 0; targetIdx--) {
|
|
const trackId = newQueue[targetIdx].Id;
|
|
if (!trackId) continue;
|
|
|
|
const currentIdx = currentPositions.get(trackId);
|
|
if (currentIdx !== undefined && currentIdx !== targetIdx) {
|
|
await TrackPlayer.move(currentIdx, targetIdx);
|
|
|
|
// Update positions map after move
|
|
currentPositions.forEach((pos, id) => {
|
|
if (currentIdx < targetIdx) {
|
|
// Moving down: items between shift up
|
|
if (pos > currentIdx && pos <= targetIdx) {
|
|
currentPositions.set(id, pos - 1);
|
|
}
|
|
} else {
|
|
// Moving up: items between shift down
|
|
if (pos >= targetIdx && pos < currentIdx) {
|
|
currentPositions.set(id, pos + 1);
|
|
}
|
|
}
|
|
});
|
|
currentPositions.set(trackId, targetIdx);
|
|
}
|
|
}
|
|
|
|
setState((prev) => ({
|
|
...prev,
|
|
queue: newQueue,
|
|
queueIndex: newIndex >= 0 ? newIndex : 0,
|
|
currentTrack: newIndex >= 0 ? newQueue[newIndex] : prev.currentTrack,
|
|
}));
|
|
},
|
|
[state.currentTrack?.Id],
|
|
);
|
|
|
|
const clearQueue = useCallback(async () => {
|
|
const currentIndex = await TrackPlayer.getActiveTrackIndex();
|
|
const queue = await TrackPlayer.getQueue();
|
|
|
|
if (currentIndex === undefined || queue.length === 0) return;
|
|
|
|
// Remove all tracks except current
|
|
const indicesToRemove = queue
|
|
.map((_: Track, i: number) => i)
|
|
.filter((i: number) => i !== currentIndex);
|
|
|
|
// Remove in reverse order to not mess up indices
|
|
for (const i of indicesToRemove.reverse()) {
|
|
await TrackPlayer.remove(i);
|
|
}
|
|
|
|
setState((prev) => {
|
|
if (!prev.currentTrack) return prev;
|
|
|
|
return {
|
|
...prev,
|
|
queue: [prev.currentTrack],
|
|
originalQueue: [prev.currentTrack],
|
|
queueIndex: 0,
|
|
};
|
|
});
|
|
}, []);
|
|
|
|
const jumpToIndex = useCallback(
|
|
async (index: number) => {
|
|
if (
|
|
index < 0 ||
|
|
index >= state.queue.length ||
|
|
index === state.queueIndex
|
|
)
|
|
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,
|
|
state.progress * 10000000,
|
|
state.playSessionId,
|
|
);
|
|
}
|
|
|
|
await TrackPlayer.skip(index);
|
|
|
|
setState((prev) => {
|
|
const mediaInfo = targetItem?.Id
|
|
? prev.trackMediaInfoMap[targetItem.Id]
|
|
: null;
|
|
return {
|
|
...prev,
|
|
queueIndex: index,
|
|
currentTrack: targetItem,
|
|
mediaSource: mediaInfo?.mediaSource ?? null,
|
|
isTranscoding: mediaInfo?.isTranscoding ?? false,
|
|
};
|
|
});
|
|
},
|
|
[
|
|
state.queue,
|
|
state.queueIndex,
|
|
state.currentTrack,
|
|
state.playSessionId,
|
|
state.progress,
|
|
reportPlaybackStopped,
|
|
settings?.preferLocalAudio,
|
|
prepareTrack,
|
|
],
|
|
);
|
|
|
|
// Modes
|
|
const setRepeatMode = useCallback((mode: RepeatMode) => {
|
|
storage.set(STORAGE_KEYS.REPEAT_MODE, mode);
|
|
setState((prev) => ({ ...prev, repeatMode: mode }));
|
|
}, []);
|
|
|
|
const toggleShuffle = useCallback(() => {
|
|
setState((prev) => {
|
|
const newShuffleEnabled = !prev.shuffleEnabled;
|
|
storage.set(STORAGE_KEYS.SHUFFLE_ENABLED, newShuffleEnabled);
|
|
|
|
if (newShuffleEnabled) {
|
|
const shuffled = shuffleArray(prev.queue, prev.queueIndex);
|
|
return {
|
|
...prev,
|
|
shuffleEnabled: true,
|
|
queue: shuffled,
|
|
queueIndex: 0,
|
|
};
|
|
} else {
|
|
const currentTrackId = prev.currentTrack?.Id;
|
|
const newIndex = prev.originalQueue.findIndex(
|
|
(t) => t.Id === currentTrackId,
|
|
);
|
|
return {
|
|
...prev,
|
|
shuffleEnabled: false,
|
|
queue: prev.originalQueue,
|
|
queueIndex: newIndex >= 0 ? newIndex : 0,
|
|
};
|
|
}
|
|
});
|
|
}, []);
|
|
|
|
const setProgress = useCallback((progress: number) => {
|
|
setState((prev) => ({ ...prev, progress }));
|
|
}, []);
|
|
|
|
const setDuration = useCallback((duration: number) => {
|
|
setState((prev) => ({ ...prev, duration }));
|
|
}, []);
|
|
|
|
const setIsPlaying = useCallback((isPlaying: boolean) => {
|
|
setState((prev) => ({ ...prev, isPlaying }));
|
|
}, []);
|
|
|
|
// 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 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: trackIndex,
|
|
currentTrack: prev.queue[trackIndex],
|
|
}));
|
|
}
|
|
}, [state.queue]);
|
|
|
|
// Called by playback engine when track ends
|
|
const onTrackEnd = useCallback(() => {
|
|
if (state.repeatMode === "one") {
|
|
TrackPlayer.seekTo(0);
|
|
TrackPlayer.play();
|
|
}
|
|
// For other modes, TrackPlayer handles it via repeat mode setting
|
|
}, [state.repeatMode]);
|
|
|
|
// Look-ahead cache: pre-cache upcoming N tracks (excludes current track to avoid bandwidth competition)
|
|
const triggerLookahead = useCallback(async () => {
|
|
// Check if caching is enabled in settings
|
|
if (settings?.audioLookaheadEnabled === false) return;
|
|
if (!api || !user?.Id) return;
|
|
|
|
try {
|
|
const tpQueue = await TrackPlayer.getQueue();
|
|
const currentIdx = await TrackPlayer.getActiveTrackIndex();
|
|
if (currentIdx === undefined || currentIdx < 0) return;
|
|
|
|
// Cache next N tracks (from settings, default 1) - excludes current to avoid bandwidth competition
|
|
const lookaheadCount = settings?.audioLookaheadCount ?? 1;
|
|
const tracksToCache = tpQueue.slice(
|
|
currentIdx + 1,
|
|
currentIdx + 1 + lookaheadCount,
|
|
);
|
|
|
|
for (const track of tracksToCache) {
|
|
const itemId = track.id;
|
|
// Skip if already stored locally or currently downloading
|
|
if (!itemId || getLocalPath(itemId) || isDownloading(itemId)) continue;
|
|
|
|
// Get stream URL for this track
|
|
const result = await getAudioStreamUrl(api, user.Id, itemId);
|
|
|
|
// Only cache direct streams (not transcoding - can't cache dynamic content)
|
|
if (result?.url && !result.isTranscoding) {
|
|
downloadTrack(itemId, result.url, {
|
|
permanent: false,
|
|
container: result.mediaSource?.Container || undefined,
|
|
}).catch(() => {
|
|
// Silent fail - caching is best-effort
|
|
});
|
|
}
|
|
}
|
|
} catch {
|
|
// Silent fail - look-ahead caching is best-effort
|
|
}
|
|
}, [
|
|
api,
|
|
user?.Id,
|
|
settings?.audioLookaheadEnabled,
|
|
settings?.audioLookaheadCount,
|
|
]);
|
|
|
|
const value = useMemo(
|
|
() => ({
|
|
...state,
|
|
playTrack,
|
|
playQueue,
|
|
playAlbum,
|
|
playPlaylist,
|
|
pause,
|
|
resume,
|
|
togglePlayPause,
|
|
next,
|
|
previous,
|
|
seek,
|
|
stop,
|
|
addToQueue,
|
|
playNext,
|
|
removeFromQueue,
|
|
moveInQueue,
|
|
reorderQueue,
|
|
clearQueue,
|
|
jumpToIndex,
|
|
setRepeatMode,
|
|
toggleShuffle,
|
|
setProgress,
|
|
setDuration,
|
|
setIsPlaying,
|
|
reportProgress: reportPlaybackProgress,
|
|
onTrackEnd,
|
|
syncFromTrackPlayer,
|
|
triggerLookahead,
|
|
}),
|
|
[
|
|
state,
|
|
playTrack,
|
|
playQueue,
|
|
playAlbum,
|
|
playPlaylist,
|
|
pause,
|
|
resume,
|
|
togglePlayPause,
|
|
next,
|
|
previous,
|
|
seek,
|
|
stop,
|
|
addToQueue,
|
|
playNext,
|
|
removeFromQueue,
|
|
moveInQueue,
|
|
reorderQueue,
|
|
clearQueue,
|
|
jumpToIndex,
|
|
setRepeatMode,
|
|
toggleShuffle,
|
|
setProgress,
|
|
setDuration,
|
|
setIsPlaying,
|
|
reportPlaybackProgress,
|
|
onTrackEnd,
|
|
syncFromTrackPlayer,
|
|
triggerLookahead,
|
|
],
|
|
);
|
|
|
|
return (
|
|
<MusicPlayerContext.Provider value={value}>
|
|
{children}
|
|
</MusicPlayerContext.Provider>
|
|
);
|
|
};
|