import { setAudioModeAsync, 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.LandingPage, 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.PlaybackImageRightsConsent, Routes.PlaybackGuide, Routes.PlaybackAi, Routes.PlaybackAiStatus, Routes.Playbacks, 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, Routes.RegistrationChoice, ]) 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 Math.round(numeric) if (numeric > 100000) return Math.round(numeric) return Math.round(numeric * 1000) } const waitForNextTick = () => new Promise((resolve) => setTimeout(resolve, 0)) 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 areSourcesEquivalent = (a, b) => { if (a === b) return true if (!a || !b) return false if (typeof a !== typeof b) return false if (typeof a === 'string' || typeof a === 'number') { return a === b } if (typeof a === 'object' && typeof b === 'object') { const aUri = typeof a?.uri === 'string' ? a.uri : null const bUri = typeof b?.uri === 'string' ? b.uri : null const aAssetId = typeof a?.assetId === 'number' ? a.assetId : null const bAssetId = typeof b?.assetId === 'number' ? b.assetId : null return aUri === bUri && aAssetId === bAssetId } return false } 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 currentTrackId = currentTrack?.id ?? 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 pendingSeekPosRef = useRef(null) const isSeekingRef = useRef(false) const isApplyingPendingSeekRef = useRef(false) const didJustFinishRef = useRef(false) const trackInfoRef = useRef({}) const playRequestIdRef = useRef(0) const reportError = useCallback((err) => { setError((prev) => { const nextMessage = String(err?.message || err || '') const prevMessage = String(prev?.message || prev || '') if (prevMessage && prevMessage === nextMessage) { return prev } return err }) }, []) 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, 100) const status = useAudioPlayerStatus(player) const shouldHideOnRoute = activeRouteName ? HIDDEN_ROUTE_NAMES.has(activeRouteName) : false const isStatusLoaded = useMemo(() => { try { return !!status?.isLoaded } catch (_error) { return false } }, [status?.isLoaded]) const statusDidJustFinish = useMemo(() => { try { return !!status?.didJustFinish } catch (_error) { return false } }, [status?.didJustFinish]) const toPlayerSeekValue = useCallback((milliseconds) => { const ms = Math.max(0, Number(milliseconds) || 0) return Platform.OS === 'web' ? ms : ms / 1000 }, []) useEffect(() => { setAudioModeAsync({ playsInSilentMode: true, shouldPlayInBackground: true, interruptionMode: 'doNotMix', allowsRecording: false, shouldRouteThroughEarpiece: false, }).catch((err) => console.warn('PlayerProvider: impossible de configurer le mode audio', err)) }, []) useEffect(() => { if (!status) return let durationMs = 0 let positionMs = 0 let isLoaded = false let isPlaying = false let isBuffering = false let didJustFinish = false try { durationMs = convertToMs(status.duration) positionMs = convertToMs(status.currentTime) isLoaded = !!status.isLoaded isPlaying = !!status.playing isBuffering = !!status.isBuffering didJustFinish = !!status.didJustFinish } catch (err) { console.warn( 'PlayerProvider: impossible de lire le statut du player', err?.message || err ) return } const previousTrackInfo = currentTrackId ? trackInfoRef.current[currentTrackId] : null const nextDurationMs = durationMs > 0 ? durationMs : previousTrackInfo?.durationMs || 0 const activePendingSeekMs = pendingSeekPosRef.current !== null ? Platform.OS === 'web' ? pendingSeekPosRef.current : pendingSeekPosRef.current * 1000 : null const queuedPendingSeekMs = pendingSeekValueRef.current !== null ? Platform.OS === 'web' ? pendingSeekValueRef.current : pendingSeekValueRef.current * 1000 : null const rawEffectivePositionMs = currentTrackId ? activePendingSeekMs ?? queuedPendingSeekMs ?? positionMs : 0 const positionQuantizationStep = activePendingSeekMs !== null || queuedPendingSeekMs !== null ? 1 : 80 const effectivePositionMs = Math.round(rawEffectivePositionMs / positionQuantizationStep) * positionQuantizationStep const effectiveDurationMs = currentTrackId ? nextDurationMs : 0 const effectiveIsPlaying = currentTrackId ? isPlaying : false const effectiveIsBuffering = currentTrackId ? isBuffering : false setPlayback((prev) => { let nextStatus = prev.status if (!currentTrackId) { 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' } if ( prev.status === nextStatus && prev.isPlaying === effectiveIsPlaying && prev.isBuffering === effectiveIsBuffering && prev.positionMs === effectivePositionMs && prev.durationMs === effectiveDurationMs ) { return prev } return { status: nextStatus, isPlaying: effectiveIsPlaying, isBuffering: effectiveIsBuffering, positionMs: effectivePositionMs, durationMs: effectiveDurationMs, } }) if (currentTrackId) { if ( !previousTrackInfo || previousTrackInfo.durationMs !== nextDurationMs || previousTrackInfo.positionMs !== positionMs || previousTrackInfo.isLoaded !== isLoaded ) { trackInfoRef.current[currentTrackId] = { durationMs: nextDurationMs, positionMs, isLoaded, updatedAt: Date.now(), } } } }, [ currentTrackId, status?.duration, status?.currentTime, status?.isLoaded, status?.playing, status?.isBuffering, status?.didJustFinish, ]) useEffect(() => { if (!player || !currentTrackId || !isStatusLoaded) return const run = async () => { if (isApplyingPendingSeekRef.current) return const pendingSeek = pendingSeekValueRef.current if (typeof pendingSeek === 'number' && pendingSeek >= 0) { pendingSeekValueRef.current = null isApplyingPendingSeekRef.current = true try { await player.seekTo?.(pendingSeek) } catch (err) { pendingSeekValueRef.current = pendingSeek reportError(err) } finally { isApplyingPendingSeekRef.current = false } } if (autoPlayRef.current) { autoPlayRef.current = false try { await player.play?.() } catch (err) { autoPlayRef.current = true reportError(err) } } } run() }, [player, currentTrackId, isStatusLoaded]) useEffect(() => { if (!player) return try { player.loop = !!isLooping } catch (_err) {} }, [player, isLooping]) const seekDebounceRef = useRef(null) useEffect(() => { return () => { if (seekDebounceRef.current) { clearTimeout(seekDebounceRef.current) seekDebounceRef.current = null } pendingSeekPosRef.current = null isSeekingRef.current = false } }, [player]) 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 return new Promise((resolve) => { seekDebounceRef.current = setTimeout(async () => { try { isSeekingRef.current = true if (isStatusLoaded) { await player.seekTo?.(seekValue) } else { pendingSeekValueRef.current = seekValue } } catch (err) { reportError(err) } finally { isSeekingRef.current = false pendingSeekPosRef.current = null resolve() } }, 100) // 100ms debounce }) }, [player, isStatusLoaded, 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 (isStatusLoaded) { await player.seekTo?.(seekValue) } else { pendingSeekValueRef.current = seekValue } } catch (err) { reportError(err) } finally { isSeekingRef.current = false } } // 2. Wait for any active seek to complete await waitForActiveSeek() }, [player, isStatusLoaded, waitForActiveSeek]) const stopCurrentTrackBeforeSwitch = useCallback(async () => { autoPlayRef.current = false pendingSeekValueRef.current = null if (seekDebounceRef.current) { clearTimeout(seekDebounceRef.current) seekDebounceRef.current = null } pendingSeekPosRef.current = null isSeekingRef.current = false isApplyingPendingSeekRef.current = false try { await flushPendingSeek() } catch (_err) {} try { await player?.pause?.() } catch (err) { reportError(err) } }, [flushPendingSeek, player, reportError]) const play = useCallback( async (trackInput, options = {}) => { const requestId = playRequestIdRef.current + 1 playRequestIdRef.current = requestId const normalized = normalizeTrack(trackInput, options) if (!normalized.source) { console.warn('PlayerProvider: impossible de lancer la lecture, source audio manquante') return } const sameTrack = currentTrackId && normalized.id === currentTrackId 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 (seekDebounceRef.current) { clearTimeout(seekDebounceRef.current) seekDebounceRef.current = null } pendingSeekPosRef.current = null isSeekingRef.current = false 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) { if (requestId !== playRequestIdRef.current) return setCurrentTrack((prev) => { if (!prev || prev.id !== normalized.id) { return normalized } const preservedSource = areSourcesEquivalent(prev.source, normalized.source) ? prev.source : normalized.source const merged = { ...prev, ...normalized, source: preservedSource, } const unchanged = prev.title === merged.title && prev.artist === merged.artist && prev.artwork === merged.artwork && prev.coverUrl === merged.coverUrl && prev.source === merged.source return unchanged ? prev : merged }) try { // Flush any pending seek first await flushPendingSeek() if (requestId !== playRequestIdRef.current) return if (targetPositionMs > 0) { await player?.seekTo?.(targetSeekValue) if (requestId !== playRequestIdRef.current) return } if (shouldAutoPlay) { await player?.play?.() } } catch (err) { reportError(err) } if (!shouldAutoPlay) { setPlayback((prev) => ({ ...prev, status: 'ready', isPlaying: false, positionMs: targetPositionMs, })) } return } if (currentTrackId) { await stopCurrentTrackBeforeSwitch() if (requestId !== playRequestIdRef.current) return setPlayback((prev) => { if ( prev.status === 'idle' && !prev.isPlaying && !prev.isBuffering && prev.positionMs === 0 && prev.durationMs === 0 ) { return prev } return { status: 'idle', isPlaying: false, isBuffering: false, positionMs: 0, durationMs: 0, } }) setCurrentTrack(null) await waitForNextTick() if (requestId !== playRequestIdRef.current) return } autoPlayRef.current = shouldAutoPlay pendingSeekValueRef.current = targetPositionMs > 0 ? targetSeekValue : null setPlayback((prev) => ({ ...prev, status: 'loading', isPlaying: false, positionMs: targetPositionMs, })) setCurrentTrack((prev) => { if (!prev) return normalized if (prev.id !== normalized.id) return normalized const preservedSource = areSourcesEquivalent(prev.source, normalized.source) ? prev.source : normalized.source const merged = { ...prev, ...normalized, source: preservedSource, } const unchanged = prev.title === merged.title && prev.artist === merged.artist && prev.artwork === merged.artwork && prev.coverUrl === merged.coverUrl && prev.source === merged.source return unchanged ? prev : merged }) }, [ currentTrackId, player, toPlayerSeekValue, updateQueue, flushPendingSeek, stopCurrentTrackBeforeSwitch, reportError, ] ) const resume = useCallback(async () => { if (!currentTrack) return try { await flushPendingSeek() await player?.play?.() } catch (err) { reportError(err) } }, [player, currentTrack, flushPendingSeek, reportError]) const pause = useCallback(async () => { try { autoPlayRef.current = false pendingSeekValueRef.current = null await player?.pause?.() } catch (err) { reportError(err) } }, [player, reportError]) 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 () => { playRequestIdRef.current += 1 try { autoPlayRef.current = false pendingSeekValueRef.current = null await player?.pause?.() } catch (err) { reportError(err) } finally { if (seekDebounceRef.current) { clearTimeout(seekDebounceRef.current) seekDebounceRef.current = null } pendingSeekPosRef.current = null isSeekingRef.current = false isApplyingPendingSeekRef.current = false setCurrentTrack(null) setPlayback({ status: 'idle', isPlaying: false, isBuffering: false, positionMs: 0, durationMs: 0, }) } }, [player, reportError]) 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 (!currentTrackId) { if (queueIndexRef.current !== -1) { setQueueIndexValue(-1) } return } const idx = queueRef.current.findIndex((item) => item.id === currentTrackId) if (idx !== queueIndexRef.current) { setQueueIndexValue(idx) } }, [currentTrackId, 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 = currentTrackId 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) => { reportError(err) }) }, [currentTrackId, isLooping, play, reportError]) useEffect(() => { const finished = statusDidJustFinish if (finished) { if (!didJustFinishRef.current) { didJustFinishRef.current = true handleTrackDidFinish() } } else { didJustFinishRef.current = false } }, [statusDidJustFinish, 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