Files
musicland/src/providers/PlayerProvider.js
T
2026-09-03 11:06:08 +02:00

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