1039 lines
29 KiB
JavaScript
1039 lines
29 KiB
JavaScript
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 (
|
|
<PlayerContext.Provider value={contextValue}>
|
|
{children}
|
|
{!shouldHideOnRoute && <GlobalAudioPlayer />}
|
|
</PlayerContext.Provider>
|
|
)
|
|
}
|
|
|
|
export default PlayerProvider
|