feat: fix player

This commit is contained in:
Victor
2026-02-27 15:50:57 +01:00
parent ae93cd4521
commit 006b65eed4
5 changed files with 385 additions and 166 deletions
+23 -9
View File
@@ -19,11 +19,10 @@ export default ({
}) => { }) => {
const offset = useSharedValue(0) const offset = useSharedValue(0)
const boxWidth = useSharedValue(INITIAL_BOX_SIZE) const boxWidth = useSharedValue(INITIAL_BOX_SIZE)
const [layout, setLayout] = useState(null) const [sliderWidth, setSliderWidth] = useState(0)
const seekingRef = useRef(false) const seekingRef = useRef(false)
const SLIDER_WIDTH = layout?.width const MAX_VALUE = Math.max(0, sliderWidth - INITIAL_BOX_SIZE)
const MAX_VALUE = SLIDER_WIDTH - INITIAL_BOX_SIZE
const handleSeekStart = () => { const handleSeekStart = () => {
if (seekEnabled && typeof onSeekStart === 'function' && !seekingRef.current) { if (seekEnabled && typeof onSeekStart === 'function' && !seekingRef.current) {
@@ -49,6 +48,11 @@ export default ({
}) })
.onChange((event) => { .onChange((event) => {
runOnJS(handleSeekStart)() runOnJS(handleSeekStart)()
if (!(MAX_VALUE > 0)) {
offset.value = 0
boxWidth.value = INITIAL_BOX_SIZE
return
}
offset.value = offset.value =
Math.abs(offset.value) <= MAX_VALUE Math.abs(offset.value) <= MAX_VALUE
? offset.value + event.changeX <= 0 ? offset.value + event.changeX <= 0
@@ -62,7 +66,7 @@ export default ({
boxWidth.value = newWidth boxWidth.value = newWidth
}) })
.onEnd(() => { .onEnd(() => {
if (seekEnabled && typeof onSeek === 'function' && MAX_VALUE) { if (seekEnabled && typeof onSeek === 'function' && MAX_VALUE > 0) {
const ratio = MAX_VALUE > 0 ? Math.min(1, Math.max(0, offset.value / MAX_VALUE)) : 0 const ratio = MAX_VALUE > 0 ? Math.min(1, Math.max(0, offset.value / MAX_VALUE)) : 0
// Reanimated -> JS thread bridge // Reanimated -> JS thread bridge
runOnJS(onSeek)(ratio) runOnJS(onSeek)(ratio)
@@ -77,15 +81,20 @@ export default ({
useEffect(() => { useEffect(() => {
if (seekingRef.current) return if (seekingRef.current) return
if (typeof progress === 'number' && layout?.width) { if (typeof progress === 'number' && sliderWidth > 0) {
const max = layout.width - INITIAL_BOX_SIZE const max = sliderWidth - INITIAL_BOX_SIZE
if (!(max > 0)) {
offset.value = 0
boxWidth.value = INITIAL_BOX_SIZE
return
}
const clamped = Math.max(0, Math.min(1, progress)) const clamped = Math.max(0, Math.min(1, progress))
const newOffset = clamped * max const newOffset = clamped * max
offset.value = newOffset offset.value = newOffset
boxWidth.value = INITIAL_BOX_SIZE + newOffset boxWidth.value = INITIAL_BOX_SIZE + newOffset
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [progress, layout?.width]) }, [progress, sliderWidth])
const boxStyle = useAnimatedStyle(() => { const boxStyle = useAnimatedStyle(() => {
return { return {
@@ -100,8 +109,13 @@ export default ({
}) })
return ( return (
<View onLayout={(e) => setLayout(e.nativeEvent.layout)}> <View
<View style={{ ...styles.sliderTrack, width: SLIDER_WIDTH }}> onLayout={(e) => {
const nextWidth = Math.max(0, Math.round(e.nativeEvent.layout?.width || 0))
setSliderWidth((prev) => (prev === nextWidth ? prev : nextWidth))
}}
>
<View style={{ ...styles.sliderTrack, width: sliderWidth || undefined }}>
<Animated.View style={[styles.box, boxStyle]}> <Animated.View style={[styles.box, boxStyle]}>
<LinearGradient <LinearGradient
colors={['#F94697', '#7023F7']} colors={['#F94697', '#7023F7']}
+25 -10
View File
@@ -25,6 +25,7 @@ const ProgressSlider = ({
disabled = false, disabled = false,
}) => { }) => {
const wasPlayingRef = useRef(false) const wasPlayingRef = useRef(false)
const pendingSeekPromiseRef = useRef(null)
const [isSeeking, setIsSeeking] = useState(false) const [isSeeking, setIsSeeking] = useState(false)
const [previewRatio, setPreviewRatio] = useState(null) const [previewRatio, setPreviewRatio] = useState(null)
@@ -54,22 +55,36 @@ const ProgressSlider = ({
const bounded = clamp01(ratio) const bounded = clamp01(ratio)
setPreviewRatio(bounded) setPreviewRatio(bounded)
const targetMs = safeDuration * bounded const targetMs = safeDuration * bounded
onSeek(targetMs) const maybePromise = onSeek(targetMs)
pendingSeekPromiseRef.current =
maybePromise && typeof maybePromise.then === 'function'
? maybePromise
: Promise.resolve()
}, },
[disabled, onSeek, safeDuration] [disabled, onSeek, safeDuration]
) )
const handleSeekEnd = useCallback(() => { const handleSeekEnd = useCallback(() => {
if (disabled || !safeDuration) return if (disabled || !safeDuration) return
setIsSeeking(false) const finalize = async () => {
setPreviewRatio(null) try {
if (typeof onSeekEnd === 'function') { if (typeof onSeekEnd === 'function') {
onSeekEnd() await onSeekEnd()
}
if (pendingSeekPromiseRef.current) {
await pendingSeekPromiseRef.current
}
} finally {
setIsSeeking(false)
setPreviewRatio(null)
pendingSeekPromiseRef.current = null
if (wasPlayingRef.current && typeof onPlay === 'function') {
onPlay()
}
wasPlayingRef.current = false
}
} }
if (wasPlayingRef.current && typeof onPlay === 'function') { finalize()
onPlay()
}
wasPlayingRef.current = false
}, [disabled, onPlay, onSeekEnd, safeDuration]) }, [disabled, onPlay, onSeekEnd, safeDuration])
return ( return (
@@ -78,7 +93,7 @@ const ProgressSlider = ({
isSeeking && previewRatio != null ? safeDuration * previewRatio : safePosition isSeeking && previewRatio != null ? safeDuration * previewRatio : safePosition
)} )}
maxValue={formatTime(safeDuration)} maxValue={formatTime(safeDuration)}
progress={progress} progress={isSeeking && previewRatio != null ? previewRatio : progress}
seekEnabled={!disabled && safeDuration > 0} seekEnabled={!disabled && safeDuration > 0}
onSeekStart={handleSeekStart} onSeekStart={handleSeekStart}
onSeek={handleSeek} onSeek={handleSeek}
+308 -71
View File
@@ -91,11 +91,13 @@ export const PlayerContext = createContext(DEFAULT_CONTEXT)
const convertToMs = (value) => { const convertToMs = (value) => {
const numeric = Number(value ?? 0) const numeric = Number(value ?? 0)
if (!Number.isFinite(numeric) || numeric <= 0) return 0 if (!Number.isFinite(numeric) || numeric <= 0) return 0
if (Platform.OS === 'web') return numeric if (Platform.OS === 'web') return Math.round(numeric)
if (numeric > 100000) return numeric if (numeric > 100000) return Math.round(numeric)
return numeric * 1000 return Math.round(numeric * 1000)
} }
const waitForNextTick = () => new Promise((resolve) => setTimeout(resolve, 0))
const ensureSource = (track = {}, options = {}) => { const ensureSource = (track = {}, options = {}) => {
const candidate = track?.source ?? options?.source ?? null const candidate = track?.source ?? options?.source ?? null
if (typeof candidate === 'number') return candidate if (typeof candidate === 'number') return candidate
@@ -141,6 +143,23 @@ const getTrackId = (track = {}, options = {}) => {
) )
} }
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 normalizeTrack = (trackInput = {}, options = {}) => {
const track = const track =
typeof trackInput === 'string' ? { uri: trackInput } : trackInput ? { ...trackInput } : {} typeof trackInput === 'string' ? { uri: trackInput } : trackInput ? { ...trackInput } : {}
@@ -197,6 +216,7 @@ const normalizeTrack = (trackInput = {}, options = {}) => {
const PlayerProvider = ({ children }) => { const PlayerProvider = ({ children }) => {
const [activeRouteName] = useGlobal('activeRouteName') const [activeRouteName] = useGlobal('activeRouteName')
const [currentTrack, setCurrentTrack] = useState(null) const [currentTrack, setCurrentTrack] = useState(null)
const currentTrackId = currentTrack?.id ?? null
const queueRef = useRef([]) const queueRef = useRef([])
const queueInfoRef = useRef({ id: null, type: null, name: null }) const queueInfoRef = useRef({ id: null, type: null, name: null })
const queueIndexRef = useRef(-1) const queueIndexRef = useRef(-1)
@@ -215,8 +235,23 @@ const PlayerProvider = ({ children }) => {
const autoPlayRef = useRef(false) const autoPlayRef = useRef(false)
const pendingSeekValueRef = useRef(null) const pendingSeekValueRef = useRef(null)
const pendingSeekPosRef = useRef(null)
const isSeekingRef = useRef(false)
const isApplyingPendingSeekRef = useRef(false)
const didJustFinishRef = useRef(false) const didJustFinishRef = useRef(false)
const trackInfoRef = useRef({}) 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 setQueueIndexValue = useCallback((index = -1) => {
const list = queueRef.current const list = queueRef.current
@@ -338,6 +373,20 @@ const PlayerProvider = ({ children }) => {
const player = useAudioPlayer(source || null, 200) const player = useAudioPlayer(source || null, 200)
const status = useAudioPlayerStatus(player) const status = useAudioPlayerStatus(player)
const shouldHideOnRoute = activeRouteName ? HIDDEN_ROUTE_NAMES.has(activeRouteName) : false 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 toPlayerSeekValue = useCallback((milliseconds) => {
const ms = Math.max(0, Number(milliseconds) || 0) const ms = Math.max(0, Number(milliseconds) || 0)
@@ -357,16 +406,57 @@ const PlayerProvider = ({ children }) => {
useEffect(() => { useEffect(() => {
if (!status) return if (!status) return
const durationMs = convertToMs(status.duration) let durationMs = 0
const positionMs = convertToMs(status.currentTime) let positionMs = 0
const isLoaded = !!status.isLoaded let isLoaded = false
const isPlaying = !!status.playing let isPlaying = false
const isBuffering = !!status.isBuffering let isBuffering = false
const didJustFinish = !!status.didJustFinish 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) => { setPlayback((prev) => {
let nextStatus = prev.status let nextStatus = prev.status
if (!currentTrack) { if (!currentTrackId) {
nextStatus = 'idle' nextStatus = 'idle'
} else if (!isLoaded) { } else if (!isLoaded) {
nextStatus = 'loading' nextStatus = 'loading'
@@ -380,49 +470,83 @@ const PlayerProvider = ({ children }) => {
nextStatus = 'paused' nextStatus = 'paused'
} }
if (
prev.status === nextStatus &&
prev.isPlaying === effectiveIsPlaying &&
prev.isBuffering === effectiveIsBuffering &&
prev.positionMs === effectivePositionMs &&
prev.durationMs === effectiveDurationMs
) {
return prev
}
return { return {
status: nextStatus, status: nextStatus,
isPlaying, isPlaying: effectiveIsPlaying,
isBuffering, isBuffering: effectiveIsBuffering,
positionMs, positionMs: effectivePositionMs,
durationMs, durationMs: effectiveDurationMs,
} }
}) })
if (currentTrack?.id) { if (currentTrackId) {
trackInfoRef.current[currentTrack.id] = { if (
durationMs: !previousTrackInfo ||
durationMs > 0 ? durationMs : trackInfoRef.current[currentTrack.id]?.durationMs || 0, previousTrackInfo.durationMs !== nextDurationMs ||
positionMs, previousTrackInfo.positionMs !== positionMs ||
isLoaded, previousTrackInfo.isLoaded !== isLoaded
updatedAt: Date.now(), ) {
trackInfoRef.current[currentTrackId] = {
durationMs: nextDurationMs,
positionMs,
isLoaded,
updatedAt: Date.now(),
}
} }
} }
}, [status, currentTrack]) }, [
currentTrackId,
status?.duration,
status?.currentTime,
status?.isLoaded,
status?.playing,
status?.isBuffering,
status?.didJustFinish,
])
useEffect(() => { useEffect(() => {
if (!player || !currentTrack) return if (!player || !currentTrackId || !isStatusLoaded) return
const run = async () => { const run = async () => {
try { if (isApplyingPendingSeekRef.current) return
const pendingSeek = pendingSeekValueRef.current
const pendingSeek = pendingSeekValueRef.current
if (typeof pendingSeek === 'number' && pendingSeek >= 0) {
pendingSeekValueRef.current = null pendingSeekValueRef.current = null
isApplyingPendingSeekRef.current = true
if (typeof pendingSeek === 'number' && pendingSeek >= 0) { try {
await player.seekTo?.(pendingSeek) await player.seekTo?.(pendingSeek)
} catch (err) {
pendingSeekValueRef.current = pendingSeek
reportError(err)
} finally {
isApplyingPendingSeekRef.current = false
} }
}
if (autoPlayRef.current) { if (autoPlayRef.current) {
autoPlayRef.current = false autoPlayRef.current = false
try {
await player.play?.() await player.play?.()
} catch (err) {
autoPlayRef.current = true
reportError(err)
} }
} catch (err) {
setError(err)
} }
} }
run() run()
}, [player, currentTrack?.id]) }, [player, currentTrackId, isStatusLoaded])
useEffect(() => { useEffect(() => {
if (!player) return if (!player) return
@@ -432,8 +556,17 @@ const PlayerProvider = ({ children }) => {
}, [player, isLooping]) }, [player, isLooping])
const seekDebounceRef = useRef(null) const seekDebounceRef = useRef(null)
const pendingSeekPosRef = useRef(null)
const isSeekingRef = useRef(false) useEffect(() => {
return () => {
if (seekDebounceRef.current) {
clearTimeout(seekDebounceRef.current)
seekDebounceRef.current = null
}
pendingSeekPosRef.current = null
isSeekingRef.current = false
}
}, [player])
const waitForActiveSeek = useCallback(async () => { const waitForActiveSeek = useCallback(async () => {
if (!isSeekingRef.current) return if (!isSeekingRef.current) return
@@ -458,23 +591,17 @@ const PlayerProvider = ({ children }) => {
// Store pending seek position // Store pending seek position
pendingSeekPosRef.current = seekValue pendingSeekPosRef.current = seekValue
// Update local state immediately for UI responsiveness
setPlayback((prev) => ({
...prev,
positionMs: bounded,
}))
return new Promise((resolve) => { return new Promise((resolve) => {
seekDebounceRef.current = setTimeout(async () => { seekDebounceRef.current = setTimeout(async () => {
try { try {
isSeekingRef.current = true isSeekingRef.current = true
if (status?.isLoaded) { if (isStatusLoaded) {
await player.seekTo?.(seekValue) await player.seekTo?.(seekValue)
} else { } else {
pendingSeekValueRef.current = seekValue pendingSeekValueRef.current = seekValue
} }
} catch (err) { } catch (err) {
setError(err) reportError(err)
} finally { } finally {
isSeekingRef.current = false isSeekingRef.current = false
pendingSeekPosRef.current = null pendingSeekPosRef.current = null
@@ -483,7 +610,7 @@ const PlayerProvider = ({ children }) => {
}, 100) // 100ms debounce }, 100) // 100ms debounce
}) })
}, },
[player, status?.isLoaded, toPlayerSeekValue] [player, isStatusLoaded, toPlayerSeekValue]
) )
const seekBy = useCallback( const seekBy = useCallback(
@@ -512,13 +639,13 @@ const PlayerProvider = ({ children }) => {
pendingSeekPosRef.current = null pendingSeekPosRef.current = null
try { try {
isSeekingRef.current = true isSeekingRef.current = true
if (status?.isLoaded) { if (isStatusLoaded) {
await player.seekTo?.(seekValue) await player.seekTo?.(seekValue)
} else { } else {
pendingSeekValueRef.current = seekValue pendingSeekValueRef.current = seekValue
} }
} catch (err) { } catch (err) {
setError(err) reportError(err)
} finally { } finally {
isSeekingRef.current = false isSeekingRef.current = false
} }
@@ -526,17 +653,43 @@ const PlayerProvider = ({ children }) => {
// 2. Wait for any active seek to complete // 2. Wait for any active seek to complete
await waitForActiveSeek() await waitForActiveSeek()
}, [player, status?.isLoaded, 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( const play = useCallback(
async (trackInput, options = {}) => { async (trackInput, options = {}) => {
const requestId = playRequestIdRef.current + 1
playRequestIdRef.current = requestId
const normalized = normalizeTrack(trackInput, options) const normalized = normalizeTrack(trackInput, options)
if (!normalized.source) { if (!normalized.source) {
console.warn('PlayerProvider: impossible de lancer la lecture, source audio manquante') console.warn('PlayerProvider: impossible de lancer la lecture, source audio manquante')
return return
} }
const sameTrack = currentTrack?.id && normalized.id === currentTrack.id const sameTrack = currentTrackId && normalized.id === currentTrackId
const targetPositionMs = const targetPositionMs =
typeof options?.startPositionMs === 'number' typeof options?.startPositionMs === 'number'
? options.startPositionMs ? options.startPositionMs
@@ -548,6 +701,13 @@ const PlayerProvider = ({ children }) => {
setError(null) setError(null)
if (seekDebounceRef.current) {
clearTimeout(seekDebounceRef.current)
seekDebounceRef.current = null
}
pendingSeekPosRef.current = null
isSeekingRef.current = false
if (Array.isArray(options.queue)) { if (Array.isArray(options.queue)) {
updateQueue(options.queue, { updateQueue(options.queue, {
id: options.queueId, id: options.queueId,
@@ -559,19 +719,43 @@ const PlayerProvider = ({ children }) => {
} }
if (sameTrack) { if (sameTrack) {
setCurrentTrack((prev) => ({ ...prev, ...normalized })) 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 { try {
// Flush any pending seek first // Flush any pending seek first
await flushPendingSeek() await flushPendingSeek()
if (requestId !== playRequestIdRef.current) return
if (targetPositionMs > 0) { if (targetPositionMs > 0) {
await player?.seekTo?.(targetSeekValue) await player?.seekTo?.(targetSeekValue)
if (requestId !== playRequestIdRef.current) return
} }
if (shouldAutoPlay) { if (shouldAutoPlay) {
await player?.play?.() await player?.play?.()
} }
} catch (err) { } catch (err) {
setError(err) reportError(err)
} }
if (!shouldAutoPlay) { if (!shouldAutoPlay) {
setPlayback((prev) => ({ setPlayback((prev) => ({
@@ -584,12 +768,31 @@ const PlayerProvider = ({ children }) => {
return return
} }
try { if (currentTrackId) {
if (player?.playing) { await stopCurrentTrackBeforeSwitch()
await player.pause?.() if (requestId !== playRequestIdRef.current) return
}
} catch (err) { setPlayback((prev) => {
setError(err) 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 autoPlayRef.current = shouldAutoPlay
@@ -601,9 +804,35 @@ const PlayerProvider = ({ children }) => {
isPlaying: false, isPlaying: false,
positionMs: targetPositionMs, positionMs: targetPositionMs,
})) }))
setCurrentTrack(normalized) 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
})
}, },
[currentTrack?.id, player, toPlayerSeekValue, updateQueue, flushPendingSeek] [
currentTrackId,
player,
toPlayerSeekValue,
updateQueue,
flushPendingSeek,
stopCurrentTrackBeforeSwitch,
reportError,
]
) )
const resume = useCallback(async () => { const resume = useCallback(async () => {
@@ -612,9 +841,9 @@ const PlayerProvider = ({ children }) => {
await flushPendingSeek() await flushPendingSeek()
await player?.play?.() await player?.play?.()
} catch (err) { } catch (err) {
setError(err) reportError(err)
} }
}, [player, currentTrack, flushPendingSeek]) }, [player, currentTrack, flushPendingSeek, reportError])
const pause = useCallback(async () => { const pause = useCallback(async () => {
try { try {
@@ -622,9 +851,9 @@ const PlayerProvider = ({ children }) => {
pendingSeekValueRef.current = null pendingSeekValueRef.current = null
await player?.pause?.() await player?.pause?.()
} catch (err) { } catch (err) {
setError(err) reportError(err)
} }
}, [player]) }, [player, reportError])
const togglePlay = useCallback( const togglePlay = useCallback(
async (trackInput, options = {}) => { async (trackInput, options = {}) => {
@@ -651,13 +880,21 @@ const PlayerProvider = ({ children }) => {
) )
const stop = useCallback(async () => { const stop = useCallback(async () => {
playRequestIdRef.current += 1
try { try {
autoPlayRef.current = false autoPlayRef.current = false
pendingSeekValueRef.current = null pendingSeekValueRef.current = null
await player?.pause?.() await player?.pause?.()
} catch (err) { } catch (err) {
setError(err) reportError(err)
} finally { } finally {
if (seekDebounceRef.current) {
clearTimeout(seekDebounceRef.current)
seekDebounceRef.current = null
}
pendingSeekPosRef.current = null
isSeekingRef.current = false
isApplyingPendingSeekRef.current = false
setCurrentTrack(null) setCurrentTrack(null)
setPlayback({ setPlayback({
status: 'idle', status: 'idle',
@@ -667,7 +904,7 @@ const PlayerProvider = ({ children }) => {
durationMs: 0, durationMs: 0,
}) })
} }
}, [player]) }, [player, reportError])
const setLooping = useCallback((value) => { const setLooping = useCallback((value) => {
setIsLooping(!!value) setIsLooping(!!value)
@@ -682,17 +919,17 @@ const PlayerProvider = ({ children }) => {
}, []) }, [])
useEffect(() => { useEffect(() => {
if (!currentTrack?.id) { if (!currentTrackId) {
if (queueIndexRef.current !== -1) { if (queueIndexRef.current !== -1) {
setQueueIndexValue(-1) setQueueIndexValue(-1)
} }
return return
} }
const idx = queueRef.current.findIndex((item) => item.id === currentTrack.id) const idx = queueRef.current.findIndex((item) => item.id === currentTrackId)
if (idx !== queueIndexRef.current) { if (idx !== queueIndexRef.current) {
setQueueIndexValue(idx) setQueueIndexValue(idx)
} }
}, [currentTrack?.id, setQueueIndexValue]) }, [currentTrackId, setQueueIndexValue])
const handleTrackDidFinish = useCallback(() => { const handleTrackDidFinish = useCallback(() => {
if (isLooping) return if (isLooping) return
@@ -700,7 +937,7 @@ const PlayerProvider = ({ children }) => {
if (!Array.isArray(items) || items.length === 0) return if (!Array.isArray(items) || items.length === 0) return
let idx = queueIndexRef.current let idx = queueIndexRef.current
const currentId = currentTrack?.id ?? null const currentId = currentTrackId
if ((idx == null || idx < 0) && currentId) { if ((idx == null || idx < 0) && currentId) {
idx = items.findIndex((item) => item.id === currentId) idx = items.findIndex((item) => item.id === currentId)
} }
@@ -718,12 +955,12 @@ const PlayerProvider = ({ children }) => {
queueType: queueInfoRef.current.type, queueType: queueInfoRef.current.type,
queueName: queueInfoRef.current.name, queueName: queueInfoRef.current.name,
}).catch((err) => { }).catch((err) => {
setError(err) reportError(err)
}) })
}, [currentTrack?.id, isLooping, play]) }, [currentTrackId, isLooping, play, reportError])
useEffect(() => { useEffect(() => {
const finished = !!status?.didJustFinish const finished = statusDidJustFinish
if (finished) { if (finished) {
if (!didJustFinishRef.current) { if (!didJustFinishRef.current) {
didJustFinishRef.current = true didJustFinishRef.current = true
@@ -732,7 +969,7 @@ const PlayerProvider = ({ children }) => {
} else { } else {
didJustFinishRef.current = false didJustFinishRef.current = false
} }
}, [status?.didJustFinish, handleTrackDidFinish]) }, [statusDidJustFinish, handleTrackDidFinish])
const contextValue = useMemo( const contextValue = useMemo(
() => ({ () => ({
+14 -2
View File
@@ -3,7 +3,7 @@ import { useDataFromRef } from 'react-native-minuit/src/hooks'
import useMinuit from 'react-native-minuit/src/hooks/useMinuit' import useMinuit from 'react-native-minuit/src/hooks/useMinuit'
import { createContext, useContext, useGlobal } from 'reactn' import { createContext, useContext, useGlobal } from 'reactn'
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { checkIfEmailIsValid } from '../actions/signupActions' import { checkIfEmailIsValid } from '../actions/signupActions'
import { showPremiumRequiredAlert } from '../components/Alert' import { showPremiumRequiredAlert } from '../components/Alert'
import firebase, { import firebase, {
@@ -126,6 +126,7 @@ export default ({ children }) => {
// Selected project state and helpers // Selected project state and helpers
const [selectedProjectId, setSelectedProjectId] = useState(null) const [selectedProjectId, setSelectedProjectId] = useState(null)
const selectedProjectIdRef = useRef(null)
const { data: selectedProject = null, setData: setSelectedProject } = useDataFromRef({ const { data: selectedProject = null, setData: setSelectedProject } = useDataFromRef({
ref: selectedProjectId ? projectsRef.doc(selectedProjectId) : null, ref: selectedProjectId ? projectsRef.doc(selectedProjectId) : null,
simpleRef: true, simpleRef: true,
@@ -134,6 +135,10 @@ export default ({ children }) => {
refreshArray: [selectedProjectId], refreshArray: [selectedProjectId],
}) })
useEffect(() => {
selectedProjectIdRef.current = selectedProjectId
}, [selectedProjectId])
const persistSelectedProjectId = useCallback( const persistSelectedProjectId = useCallback(
async (projectId) => { async (projectId) => {
if (!currentUID) return if (!currentUID) return
@@ -144,7 +149,6 @@ export default ({ children }) => {
}, },
{ merge: true } { merge: true }
) )
console.log('update selected project')
} catch (error) { } catch (error) {
console.log( console.log(
'UserDataProvider: unable to persist selectedProjectId', 'UserDataProvider: unable to persist selectedProjectId',
@@ -158,6 +162,14 @@ export default ({ children }) => {
const selectProject = useCallback( const selectProject = useCallback(
(projectId) => { (projectId) => {
const safeId = projectId || null const safeId = projectId || null
if (safeId === selectedProjectIdRef.current) {
if (!safeId) {
setSelectedProject(null)
}
return
}
selectedProjectIdRef.current = safeId
setSelectedProjectId(safeId) setSelectedProjectId(safeId)
if (!safeId) { if (!safeId) {
setSelectedProject(null) setSelectedProject(null)
+15 -74
View File
@@ -15,7 +15,7 @@ import { responsiveHeight, responsiveWidth } from 'react-native-responsive-dimen
import { useGlobal } from 'reactn' import { useGlobal } from 'reactn'
import { background, icons } from '../../assets' import { background, icons } from '../../assets'
import PressableScale from '../../components/PressableScale' import PressableScale from '../../components/PressableScale'
import Slider from '../../components/Slider' import ProgressSlider from '../../components/player/ProgressSlider'
import { arrayRemove, arrayUnion, increment, projectsRef, usersRef } from '../../config/firebase' import { arrayRemove, arrayUnion, increment, projectsRef, usersRef } from '../../config/firebase'
import useDataFromRef from '../../hooks/useDataFromRef' import useDataFromRef from '../../hooks/useDataFromRef'
import usePlayer from '../../hooks/usePlayer' import usePlayer from '../../hooks/usePlayer'
@@ -44,9 +44,6 @@ const MusicDetails = ({ route }) => {
const projectId = params?.projectId || null const projectId = params?.projectId || null
const [fav, setFav] = useState(false) const [fav, setFav] = useState(false)
const [currentUID] = useGlobal('currentUID') const [currentUID] = useGlobal('currentUID')
const wasPlayingBeforeSeek = useRef(false)
const hasCapturedSeekStateRef = useRef(false)
const lastSeekTargetMsRef = useRef(null)
const listenedMsRef = useRef(0) const listenedMsRef = useRef(0)
const incrementDoneRef = useRef(false) const incrementDoneRef = useRef(false)
const timerRef = useRef(null) const timerRef = useRef(null)
@@ -274,15 +271,6 @@ const MusicDetails = ({ route }) => {
return clearTimer return clearTimer
}, [isTrackPlaying, projectId]) }, [isTrackPlaying, projectId])
const fmt = (ms) => {
const total = Math.max(0, Math.floor((ms || 0) / 1000))
const m = Math.floor(total / 60)
.toString()
.padStart(1, '0')
const s = (total % 60).toString().padStart(2, '0')
return `${m}:${s}`
}
const togglePlay = useCallback(async () => { const togglePlay = useCallback(async () => {
if (!trackDescriptor) return if (!trackDescriptor) return
try { try {
@@ -308,42 +296,26 @@ const MusicDetails = ({ route }) => {
resumeTrack, resumeTrack,
]) ])
const handleSliderSeekStart = useCallback(async () => { const handleSliderSeekStart = useCallback(() => {
if (!trackDescriptor) return if (!trackDescriptor) return
lastSeekTargetMsRef.current = null }, [trackDescriptor])
if (!hasCapturedSeekStateRef.current) {
hasCapturedSeekStateRef.current = true
wasPlayingBeforeSeek.current = isTrackPlaying
}
try {
if (!isCurrentTrack) {
await ensureLoaded({ startPositionMs: positionMs, autoPlay: false })
}
if (isTrackPlaying) {
await pauseTrack()
}
} catch (e) {
console.log('MusicDetails seek start error', e?.message)
}
}, [trackDescriptor, isTrackPlaying, isCurrentTrack, ensureLoaded, positionMs, pauseTrack])
const handleSliderSeek = useCallback( const handleSliderSeek = useCallback(
async (ratio) => { async (targetMs) => {
const dur = sliderDurationMs || 0 const dur = sliderDurationMs || 0
if (!trackDescriptor || dur <= 0) return if (!trackDescriptor || dur <= 0) return
const targetMs = Math.max(0, Math.floor(dur * ratio)) const bounded = Math.max(0, Math.min(dur, Math.floor(targetMs)))
lastSeekTargetMsRef.current = targetMs
try { try {
if (!isCurrentTrack) { if (!isCurrentTrack) {
await ensureLoaded({ startPositionMs: targetMs, autoPlay: false }) await ensureLoaded({ startPositionMs: bounded, autoPlay: false })
} else { } else {
await seekTrackTo(targetMs) await seekTrackTo(bounded)
} }
} catch (e) { } catch (e) {
console.log('MusicDetails seek error', e?.message) console.log('MusicDetails seek error', e?.message)
} }
}, },
[sliderDurationMs, trackDescriptor, isCurrentTrack, ensureLoaded, seekTrackTo] [trackDescriptor, sliderDurationMs, isCurrentTrack, ensureLoaded, seekTrackTo]
) )
const handleToggleLoop = useCallback(async () => { const handleToggleLoop = useCallback(async () => {
@@ -392,34 +364,6 @@ const MusicDetails = ({ route }) => {
resumeTrack, resumeTrack,
]) ])
const handleSliderSeekEnd = useCallback(async () => {
const targetMs =
typeof lastSeekTargetMsRef.current === 'number'
? Math.max(0, lastSeekTargetMsRef.current)
: null
try {
if (wasPlayingBeforeSeek.current) {
if (!isCurrentTrack) {
await ensureLoaded({
startPositionMs: targetMs !== null && Number.isFinite(targetMs) ? targetMs : positionMs,
autoPlay: true,
})
} else {
if (targetMs !== null && Number.isFinite(targetMs)) {
await seekTrackTo(targetMs)
}
await resumeTrack()
}
}
} catch (e) {
console.log('MusicDetails seek end error', e?.message)
} finally {
wasPlayingBeforeSeek.current = false
hasCapturedSeekStateRef.current = false
lastSeekTargetMsRef.current = null
}
}, [ensureLoaded, isCurrentTrack, positionMs, resumeTrack, seekTrackTo])
const handleSeekBySeconds = useCallback( const handleSeekBySeconds = useCallback(
async (deltaSeconds) => { async (deltaSeconds) => {
if (!trackDescriptor) return if (!trackDescriptor) return
@@ -816,18 +760,15 @@ const MusicDetails = ({ route }) => {
</View> </View>
{songUrl && ( {songUrl && (
<View style={{ paddingTop: 22 }}> <View style={{ paddingTop: 22 }}>
<Slider <ProgressSlider
value={fmt(positionMs)} positionMs={positionMs}
maxValue={fmt(sliderDurationMs)} durationMs={sliderDurationMs}
progress={ isPlaying={isTrackPlaying}
sliderDurationMs
? Math.min(1, Math.max(0, (positionMs || 0) / sliderDurationMs))
: 0
}
seekEnabled={!!songUrl}
onSeekStart={handleSliderSeekStart} onSeekStart={handleSliderSeekStart}
onSeek={handleSliderSeek} onSeek={handleSliderSeek}
onSeekEnd={handleSliderSeekEnd} onPause={pauseTrack}
onPlay={resumeTrack}
disabled={!songUrl}
/> />
<View style={{ ...Style.containerRow, gap: 24, alignSelf: 'center' }}> <View style={{ ...Style.containerRow, gap: 24, alignSelf: 'center' }}>
{/* Previous (rewind 10s) */} {/* Previous (rewind 10s) */}