import { useAudioPlayer, useAudioPlayerStatus } from "expo-audio"; import React, { createContext, useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { Platform } from "react-native"; import { useGlobal } from "reactn"; import GlobalAudioPlayer from "../components/player/GlobalAudioPlayer"; import { Routes } from "../navigation/Routes"; const HIDDEN_ROUTE_NAMES = new Set([ Routes.Writing, Routes.WritingLyrics, Routes.CreateLyricsWithAi, Routes.Lyrics, Routes.FinishedWriting, Routes.Studio, Routes.Compose, Routes.ComposeSong, Routes.CustomizeVoice, Routes.SongReady, Routes.Regenerate, Routes.PouchReady, Routes.PhotoCover, Routes.ValidateCover, Routes.SongDownload, Routes.FinishCompose, Routes.GeneratingSong, Routes.ProductionOnboarding, Routes.Production, Routes.PlaybackDownload, Routes.DownloadPrices, Routes.SongDownloaded, Routes.StreamSong, Routes.SongRelease, Routes.PlaybackExample, Routes.PlaybackOnboarding, Routes.Playback, Routes.RecordPlayback, Routes.RecordedPlayback, Routes.ChooseDecor, Routes.CreatingDecor, Routes.VideoFinalize, Routes.FlowSelection, Routes.ChooseCoverType, Routes.Settings, Routes.ProjectSettings, Routes.AccountSettings, Routes.ChangeEmailAddress, Routes.ChangePassword, Routes.Notifications, Routes.Language, Routes.Payments, Routes.Login, Routes.ResetPassword, Routes.Register, ]); const noopAsync = async () => { }; const noop = () => { }; const DEFAULT_CONTEXT = { currentTrack: null, queue: [], queueInfo: null, queueIndex: -1, status: "idle", isPlaying: false, isBuffering: false, positionMs: 0, durationMs: 0, error: null, isPlayerVisible: true, play: noopAsync, togglePlay: noopAsync, pause: noopAsync, resume: noopAsync, seekTo: noopAsync, seekBy: noopAsync, stop: noopAsync, setQueue: noop, isLooping: false, setLooping: noop, toggleLooping: noop, getTrackInfo: () => null, }; export const PlayerContext = createContext(DEFAULT_CONTEXT); const convertToMs = (value) => { const numeric = Number(value ?? 0); if (!Number.isFinite(numeric) || numeric <= 0) return 0; if (Platform.OS === "web") return numeric; if (numeric > 100000) return numeric; return numeric * 1000; }; const ensureSource = (track = {}, options = {}) => { const candidate = track?.source ?? options?.source ?? null; if (typeof candidate === "number") return candidate; if (candidate && typeof candidate === "object") return candidate; if (typeof candidate === "string") return { uri: candidate }; const uriCandidate = track?.uri ?? track?.url ?? track?.songUrl ?? track?.audioUrl ?? track?.musicUrl ?? (typeof track === "string" ? track : null) ?? options?.uri ?? options?.url ?? options?.songUrl ?? options?.audioUrl ?? null; if (typeof uriCandidate === "number") return uriCandidate; if (uriCandidate && typeof uriCandidate === "object") return uriCandidate; if (typeof uriCandidate === "string" && uriCandidate.length) { return { uri: uriCandidate }; } return null; }; const getTrackId = (track = {}, options = {}) => { if (typeof track === "string") return track; return ( track?.id ?? options?.id ?? track?.projectId ?? track?.songId ?? track?.uri ?? track?.url ?? track?.songUrl ?? track?.musicUrl ?? options?.uri ?? options?.url ?? options?.songUrl ?? null ); }; const normalizeTrack = (trackInput = {}, options = {}) => { const track = typeof trackInput === "string" ? { uri: trackInput } : trackInput ? { ...trackInput } : {}; const source = ensureSource(track, options); const explicitId = getTrackId(track, options); let id = explicitId; if (!id && typeof source === "string") { id = source; } if (!id && typeof source === "number") { id = String(source); } if ( !id && source && typeof source === "object" && typeof source.uri === "string" ) { id = source.uri; } if (!id) { id = `track-${Date.now()}`; } const titleCandidate = typeof track?.title === "string" ? track.title : typeof options?.title === "string" ? options.title : ""; const artistCandidate = typeof track?.artist === "string" ? track.artist : typeof options?.artist === "string" ? options.artist : ""; const artwork = track?.artwork ?? track?.coverUrl ?? options?.artwork ?? options?.coverUrl ?? null; return { id, title: titleCandidate, artist: artistCandidate, artwork, coverUrl: track?.coverUrl ?? options?.coverUrl ?? null, source, metadata: { ...(options?.metadata || {}), ...(track?.metadata || {}), }, context: options?.context ?? track?.context ?? null, }; }; const PlayerProvider = ({ children }) => { const [activeRouteName] = useGlobal("activeRouteName"); const [currentTrack, setCurrentTrack] = useState(null); const queueRef = useRef([]); const queueInfoRef = useRef({ id: null, type: null, name: null }); const queueIndexRef = useRef(-1); const [queue, setQueueState] = useState([]); const [queueInfo, setQueueInfoState] = useState(queueInfoRef.current); const [queueIndex, setQueueIndexState] = useState(queueIndexRef.current); const [playback, setPlayback] = useState({ status: "idle", isPlaying: false, isBuffering: false, positionMs: 0, durationMs: 0, }); const [error, setError] = useState(null); const [isLooping, setIsLooping] = useState(false); const autoPlayRef = useRef(false); const pendingSeekValueRef = useRef(null); const didJustFinishRef = useRef(false); const trackInfoRef = useRef({}); const setQueueIndexValue = useCallback((index = -1) => { const list = queueRef.current; const hasItems = Array.isArray(list) && list.length > 0; let target = Number.isInteger(index) ? index : queueIndexRef.current; if (!hasItems) { target = -1; } else { target = Math.min(Math.max(target, -1), list.length - 1); } if (queueIndexRef.current === target) return; queueIndexRef.current = target; setQueueIndexState(target); }, []); const setQueueInfoValue = useCallback((info) => { const next = { id: info?.id ?? null, type: info?.type ?? null, name: info?.name ?? null, }; const prev = queueInfoRef.current; if ( prev.id === next.id && prev.type === next.type && prev.name === next.name ) { return; } queueInfoRef.current = next; setQueueInfoState(next); }, []); const updateQueue = useCallback( (items = [], options = {}) => { const normalized = Array.isArray(items) ? items .map((item) => { if (!item) return null; if (typeof item === "object") { const { metadata, context, ...rest } = item; const safeMetadata = metadata && typeof metadata === "object" ? { ...metadata } : undefined; if (safeMetadata) { delete safeMetadata.queue; } const safeContext = context && typeof context === "object" ? { ...context } : undefined; if (safeContext) { delete safeContext.queue; } return normalizeTrack( { ...rest, ...(safeMetadata ? { metadata: safeMetadata } : {}), ...(safeContext ? { context: safeContext } : {}), }, {} ); } return normalizeTrack(item, {}); }) .filter((track) => !!track?.source) : []; queueRef.current = normalized; setQueueState(normalized); const info = { id: options.id ?? options.queueId ?? null, type: options.type ?? options.queueType ?? null, name: options.name ?? options.queueName ?? null, }; setQueueInfoValue(info); const currentTrackId = options.currentTrackId && typeof options.currentTrackId === "string" ? options.currentTrackId : null; const indexCandidate = typeof options.index === "number" ? options.index : typeof options.queueIndex === "number" ? options.queueIndex : currentTrackId ? normalized.findIndex((track) => track.id === currentTrackId) : queueIndexRef.current; setQueueIndexValue(indexCandidate); return normalized; }, [setQueueIndexValue, setQueueInfoValue] ); const setQueue = useCallback( (itemsOrConfig, maybeOptions = {}) => { if (typeof itemsOrConfig === "function") { const result = itemsOrConfig([...(queueRef.current || [])]); if (Array.isArray(result)) { updateQueue(result, maybeOptions); } return; } if (Array.isArray(itemsOrConfig)) { updateQueue(itemsOrConfig, maybeOptions); return; } if (itemsOrConfig && typeof itemsOrConfig === "object") { const { items = [], ...rest } = itemsOrConfig; updateQueue(items, { ...rest, ...maybeOptions }); return; } updateQueue([], {}); }, [updateQueue] ); const source = useMemo(() => { if (!currentTrack?.source) return null; return currentTrack.source; }, [currentTrack?.source]); const player = useAudioPlayer(source || null, 200); const status = useAudioPlayerStatus(player); const shouldHideOnRoute = activeRouteName ? HIDDEN_ROUTE_NAMES.has(activeRouteName) : false; const toPlayerSeekValue = useCallback((milliseconds) => { const ms = Math.max(0, Number(milliseconds) || 0); return Platform.OS === "web" ? ms : ms / 1000; }, []); useEffect(() => { if (!status) return; const durationMs = convertToMs(status.duration); const positionMs = convertToMs(status.currentTime); const isLoaded = !!status.isLoaded; const isPlaying = !!status.playing; const isBuffering = !!status.isBuffering; const didJustFinish = !!status.didJustFinish; setPlayback((prev) => { let nextStatus = prev.status; if (!currentTrack) { nextStatus = "idle"; } else if (!isLoaded) { nextStatus = "loading"; } else if (didJustFinish) { nextStatus = "ended"; } else if (isPlaying) { nextStatus = "playing"; } else if (prev.status === "loading" && isLoaded) { nextStatus = "ready"; } else if (!isPlaying && isLoaded) { nextStatus = "paused"; } return { status: nextStatus, isPlaying, isBuffering, positionMs, durationMs, }; }); if (currentTrack?.id) { trackInfoRef.current[currentTrack.id] = { durationMs: durationMs > 0 ? durationMs : trackInfoRef.current[currentTrack.id]?.durationMs || 0, positionMs, isLoaded, updatedAt: Date.now(), }; } }, [status, currentTrack]); useEffect(() => { if (!player || !currentTrack) return; const run = async () => { try { const pendingSeek = pendingSeekValueRef.current; pendingSeekValueRef.current = null; if (typeof pendingSeek === "number" && pendingSeek >= 0) { await player.seekTo?.(pendingSeek); } if (autoPlayRef.current) { autoPlayRef.current = false; await player.play?.(); } } catch (err) { setError(err); } }; run(); }, [player, currentTrack?.id]); useEffect(() => { if (!player) return; try { player.loop = !!isLooping; } catch (_err) { } }, [player, isLooping]); const seekDebounceRef = useRef(null); const pendingSeekPosRef = useRef(null); const isSeekingRef = useRef(false); const waitForActiveSeek = useCallback(async () => { if (!isSeekingRef.current) return; // Poll every 50ms until seek is done while (isSeekingRef.current) { await new Promise((resolve) => setTimeout(resolve, 50)); } }, []); const seekTo = useCallback( async (positionMs) => { if (!player) return; const bounded = Math.max(0, Number(positionMs) || 0); const seekValue = toPlayerSeekValue(bounded); // Cancel any pending debounce if (seekDebounceRef.current) { clearTimeout(seekDebounceRef.current); seekDebounceRef.current = null; } // Store pending seek position pendingSeekPosRef.current = seekValue; // Update local state immediately for UI responsiveness setPlayback((prev) => ({ ...prev, positionMs: bounded, })); return new Promise((resolve) => { seekDebounceRef.current = setTimeout(async () => { try { isSeekingRef.current = true; if (status?.isLoaded) { await player.seekTo?.(seekValue); } else { pendingSeekValueRef.current = seekValue; } } catch (err) { setError(err); } finally { isSeekingRef.current = false; pendingSeekPosRef.current = null; resolve(); } }, 100); // 100ms debounce }); }, [player, status?.isLoaded, toPlayerSeekValue] ); const seekBy = useCallback( async (deltaMs) => { const currentPos = pendingSeekPosRef.current !== null ? (Platform.OS === "web" ? pendingSeekPosRef.current : pendingSeekPosRef.current * 1000) : playback.positionMs; const next = Math.max(0, currentPos + Number(deltaMs || 0)); await seekTo(next); }, [playback.positionMs, seekTo] ); // Helper to flush pending seek before playing const flushPendingSeek = useCallback(async () => { // 1. Cancel pending debounce and execute immediately if (seekDebounceRef.current) { clearTimeout(seekDebounceRef.current); seekDebounceRef.current = null; } if (pendingSeekPosRef.current !== null) { const seekValue = pendingSeekPosRef.current; pendingSeekPosRef.current = null; try { isSeekingRef.current = true; if (status?.isLoaded) { await player.seekTo?.(seekValue); } else { pendingSeekValueRef.current = seekValue; } } catch (err) { setError(err); } finally { isSeekingRef.current = false; } } // 2. Wait for any active seek to complete await waitForActiveSeek(); }, [player, status?.isLoaded, waitForActiveSeek]); const play = useCallback( async (trackInput, options = {}) => { const normalized = normalizeTrack(trackInput, options); if (!normalized.source) { console.warn( "PlayerProvider: impossible de lancer la lecture, source audio manquante" ); return; } const sameTrack = currentTrack?.id && normalized.id === currentTrack.id; const targetPositionMs = typeof options?.startPositionMs === "number" ? options.startPositionMs : typeof options?.positionMs === "number" ? options.positionMs : 0; const targetSeekValue = toPlayerSeekValue(targetPositionMs); const shouldAutoPlay = options?.autoPlay !== false; setError(null); if (Array.isArray(options.queue)) { updateQueue(options.queue, { id: options.queueId, type: options.queueType, name: options.queueName, queueIndex: typeof options.queueIndex === "number" ? options.queueIndex : undefined, currentTrackId: normalized.id, }); } if (sameTrack) { setCurrentTrack((prev) => ({ ...prev, ...normalized })); try { // Flush any pending seek first await flushPendingSeek(); if (targetPositionMs > 0) { await player?.seekTo?.(targetSeekValue); } if (shouldAutoPlay) { await player?.play?.(); } } catch (err) { setError(err); } if (!shouldAutoPlay) { setPlayback((prev) => ({ ...prev, status: "ready", isPlaying: false, positionMs: targetPositionMs, })); } return; } try { if (player?.playing) { await player.pause?.(); } } catch (err) { setError(err); } autoPlayRef.current = shouldAutoPlay; pendingSeekValueRef.current = targetPositionMs > 0 ? targetSeekValue : null; setPlayback((prev) => ({ ...prev, status: "loading", isPlaying: false, positionMs: targetPositionMs, })); setCurrentTrack(normalized); }, [currentTrack?.id, player, toPlayerSeekValue, updateQueue, flushPendingSeek] ); const resume = useCallback(async () => { if (!currentTrack) return; try { await flushPendingSeek(); await player?.play?.(); } catch (err) { setError(err); } }, [player, currentTrack, flushPendingSeek]); const pause = useCallback(async () => { try { autoPlayRef.current = false; pendingSeekValueRef.current = null; await player?.pause?.(); } catch (err) { setError(err); } }, [player]); const togglePlay = useCallback( async (trackInput, options = {}) => { if (!trackInput && !currentTrack) return; const targetId = getTrackId( typeof trackInput === "string" ? { uri: trackInput } : trackInput, options ); if ( trackInput && (!currentTrack || (targetId && targetId !== currentTrack.id)) ) { await play(trackInput, options); return; } if (playback.isPlaying) { await pause(); } else if (currentTrack) { await resume(); } else if (trackInput) { await play(trackInput, options); } }, [currentTrack, playback.isPlaying, pause, play, resume] ); const stop = useCallback(async () => { try { autoPlayRef.current = false; pendingSeekValueRef.current = null; await player?.pause?.(); } catch (err) { setError(err); } finally { setCurrentTrack(null); setPlayback({ status: "idle", isPlaying: false, isBuffering: false, positionMs: 0, durationMs: 0, }); } }, [player]); const setLooping = useCallback((value) => { setIsLooping(!!value); }, []); const toggleLooping = useCallback(() => { setIsLooping((prev) => !prev); }, []); const getTrackInfo = useCallback((trackId) => { if (!trackId) return null; return trackInfoRef.current[trackId] || null; }, []); useEffect(() => { if (!currentTrack?.id) { if (queueIndexRef.current !== -1) { setQueueIndexValue(-1); } return; } const idx = queueRef.current.findIndex((item) => item.id === currentTrack.id); if (idx !== queueIndexRef.current) { setQueueIndexValue(idx); } }, [currentTrack?.id, setQueueIndexValue]); const handleTrackDidFinish = useCallback(() => { if (isLooping) return; const items = queueRef.current; if (!Array.isArray(items) || items.length === 0) return; let idx = queueIndexRef.current; const currentId = currentTrack?.id ?? null; if ((idx == null || idx < 0) && currentId) { idx = items.findIndex((item) => item.id === currentId); } if (idx == null || idx < 0) return; const nextIdx = idx + 1; if (nextIdx >= items.length) return; const nextTrack = items[nextIdx]; if (!nextTrack) return; play(nextTrack, { autoPlay: true, startPositionMs: 0, queueId: queueInfoRef.current.id, queueType: queueInfoRef.current.type, queueName: queueInfoRef.current.name, }).catch((err) => { setError(err); }); }, [currentTrack?.id, isLooping, play]); useEffect(() => { const finished = !!status?.didJustFinish; if (finished) { if (!didJustFinishRef.current) { didJustFinishRef.current = true; handleTrackDidFinish(); } } else { didJustFinishRef.current = false; } }, [status?.didJustFinish, handleTrackDidFinish]); const contextValue = useMemo( () => ({ currentTrack, queue, queueInfo, queueIndex, status: playback.status, isPlaying: playback.isPlaying, isBuffering: playback.isBuffering, positionMs: playback.positionMs, durationMs: playback.durationMs, error, isPlayerVisible: !shouldHideOnRoute, play, togglePlay, pause, resume, seekTo, seekBy, stop, setQueue, isLooping, setLooping, toggleLooping, getTrackInfo, }), [ currentTrack, error, pause, play, playback.durationMs, playback.isBuffering, playback.isPlaying, playback.positionMs, playback.status, queue, queueInfo, queueIndex, resume, seekBy, seekTo, shouldHideOnRoute, stop, isLooping, setLooping, toggleLooping, setQueue, getTrackInfo, ] ); return ( {children} {!shouldHideOnRoute && } ); }; export default PlayerProvider;