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