From 372b59660fe49a58f10161b9f15e3872b1f3be64 Mon Sep 17 00:00:00 2001 From: Leon Date: Tue, 3 Mar 2026 15:24:38 +0100 Subject: [PATCH] feat: merge audio --- src/screens/Library/MusicDetails.js | 111 +++++---- src/screens/Production/PlaybackDownload.js | 274 +++++---------------- 2 files changed, 125 insertions(+), 260 deletions(-) diff --git a/src/screens/Library/MusicDetails.js b/src/screens/Library/MusicDetails.js index 7c7bc68..b5cd4b1 100644 --- a/src/screens/Library/MusicDetails.js +++ b/src/screens/Library/MusicDetails.js @@ -27,7 +27,7 @@ import Style, { gutters, size } from '../../styles/Style' import { getArtistDisplayName } from '../../utils/artistName' import { ensureAuthenticated } from '../../utils/authRedirect' import { createMusicSharePayload, openShareSheet } from '../../utils/shareSheet' -// Import de la fonction utilitaire (à créer dans src/utils/lyricsUtils.js) +// Assure-toi que le fichier lyricsUtils.js contient la fonction processKaraokeSections fournie précédemment import { processKaraokeSections } from './utils/lyricsUtils' const timeBeforeIncrement = 20000 @@ -50,7 +50,7 @@ const MusicDetails = ({ route }) => { const { isLooping, setLooping } = usePlayer() || {} - // --- DATA --- + // --- DATA FETCHING --- const { data: project } = useDataFromRef({ ref: projectId ? projectsRef.doc(projectId) : null, simpleRef: true, @@ -65,7 +65,7 @@ const MusicDetails = ({ route }) => { condition: !!project?.userId, }) - // --- MEMOS & STABILISATION --- + // --- METADATA & PLAYER STABILIZATION --- const title = project?.title || 'Sans titre' const artist = useMemo(() => { const ownerName = getArtistDisplayName(owner, '') @@ -76,7 +76,7 @@ const MusicDetails = ({ route }) => { const coverUrl = project?.coverUrl || null const songUrl = project?.songUrl || null - // IMPORTANT: Stabiliser cet objet pour éviter les sauts iOS + // On mémoïse le descriptor pour éviter les re-chargements intempestifs sur iOS const trackDescriptor = useMemo(() => { if (!songUrl) return null const id = projectId ? `project-${projectId}` : `song-${songUrl}` @@ -91,11 +91,10 @@ const MusicDetails = ({ route }) => { metadata: { projectId, screen: 'MusicDetails' }, context: { projectId, screen: 'MusicDetails' }, } - console.log('[DEBUG PLAYER] Nouveau Track Descriptor:', desc.id) + console.log('[PLAYER] Nouveau descriptor généré:', id) return desc }, [projectId, songUrl, title, artist, coverUrl]) - // --- CONTROLLER --- const { isCurrent, isPlaying, @@ -108,9 +107,30 @@ const MusicDetails = ({ route }) => { seekBy, } = useTrackController(trackDescriptor) - const loopEnabled = isCurrent && !!isLooping + // --- DURATION CALCULATIONS (Pour le clic sur le slider) --- + const estimatedDurationMs = useMemo(() => { + const toMs = (val) => { + const n = Number(val); return (!n || n <= 0) ? 0 : (n > 1000 ? Math.round(n) : Math.round(n * 1000)) + } + const idx = Number(project?.songIndex) || 0 + const ts = project?.musicTimestamps?.[idx] - // --- LOGIQUE LYRICS --- + // Priorité 1: Metadata + const fromMeta = toMs(ts?.durationMs || ts?.duration || ts?.audioDuration) + if (fromMeta > 0) return fromMeta + + // Priorité 2: Dernier mot synchronisé + const words = ts?.alignedWords || [] + if (words.length > 0) { + const last = words[words.length - 1] + return Math.round((last.endS || last.startS || 0) * 1000) + } + return 0 + }, [project]) + + const sliderDurationMs = durationMs > 0 ? durationMs : estimatedDurationMs + + // --- LYRICS LOGIC --- const sections = useMemo(() => { const idx = Number(project?.songIndex) || 0 const ts = project?.musicTimestamps?.[idx] @@ -118,9 +138,7 @@ const MusicDetails = ({ route }) => { }, [project?.musicTimestamps, project?.songIndex]) const flatLines = useMemo(() => sections.flatMap((s) => s.lines), [sections]) - - const currentTimeS = (positionMs || 0) / 1000 - const visibleTimeS = currentTimeS + 0.3 // Offset pour fluidité iOS + const visibleTimeS = (positionMs || 0) / 1000 + 0.3 // Offset de 300ms pour fluidité visuelle const currentLineIdx = useMemo(() => { if (!flatLines.length) return -1 @@ -128,31 +146,26 @@ const MusicDetails = ({ route }) => { }, [flatLines, visibleTimeS]) // --- EFFECTS --- - - // Sync favoris useEffect(() => { if (project && currentUID) { setFav(Array.isArray(project.likedBy) ? project.likedBy.includes(currentUID) : false) } }, [project?.likedBy, currentUID]) - // Timer pour les vues + // Vues useEffect(() => { - const clearTimer = () => { if (timerRef.current) clearInterval(timerRef.current) } + const clear = () => timerRef.current && clearInterval(timerRef.current) if (isPlaying && projectId && !incrementDoneRef.current) { - timerRef.current = setInterval(async () => { + timerRef.current = setInterval(() => { listenedMsRef.current += 1000 if (listenedMsRef.current >= timeBeforeIncrement) { - console.log('[DEBUG] Incrémentation vue pour:', projectId) + projectsRef.doc(projectId).update({ views: increment(1) }).catch(() => {}) incrementDoneRef.current = true - projectsRef.doc(projectId).set({ views: increment(1) }, { merge: true }).catch(() => {}) - clearTimer() + clear() } }, 1000) - } else { - clearTimer() - } - return clearTimer + } else { clear() } + return clear }, [isPlaying, projectId]) // Auto-scroll @@ -163,56 +176,59 @@ const MusicDetails = ({ route }) => { } }, [currentLineIdx]) - // Autoplay requested + // Autoplay useEffect(() => { if (autoPlayRequested && !hasAutoPlayedRef.current && trackDescriptor) { - const start = async () => { + const run = async () => { try { if (!isCurrent) await ensureLoaded({ startPositionMs: 0, autoPlay: true }) else if (!isPlaying) await resume() hasAutoPlayedRef.current = true - } catch (e) { console.log('[PLAYER ERROR] Autoplay:', e.message) } + } catch (e) { console.log('[PLAYER] Autoplay error:', e.message) } } - start() + run() } }, [autoPlayRequested, trackDescriptor, isCurrent, isPlaying, ensureLoaded, resume]) - // --- ACTIONS --- + // --- CALLBACKS --- const togglePlay = useCallback(async () => { if (!trackDescriptor) return try { if (!isCurrent) { - console.log('[PLAYER] Chargement nouveau media:', trackDescriptor.title) + console.log('[PLAYER] Chargement via togglePlay') await ensureLoaded({ startPositionMs: positionMs, autoPlay: true }) } else { isPlaying ? await pause() : await resume() } - } catch (e) { console.log('[PLAYER ERROR] Toggle:', e.message) } + } catch (e) { console.log('[PLAYER] Toggle error:', e.message) } }, [trackDescriptor, isCurrent, isPlaying, ensureLoaded, positionMs, pause, resume]) - const handleSliderSeek = useCallback(async (ms) => { - if (!trackDescriptor) return + const handleSliderSeek = useCallback(async (targetMs) => { + if (!trackDescriptor || sliderDurationMs <= 0) return + const ms = Math.max(0, Math.min(sliderDurationMs, Math.floor(targetMs))) + console.log(`[PLAYER] Seek demandé vers: ${ms}ms`) try { - if (!isCurrent) await ensureLoaded({ startPositionMs: ms, autoPlay: false }) - else await seekTo(Math.floor(ms)) - } catch (e) { console.log('[PLAYER ERROR] Seek:', e.message) } - }, [trackDescriptor, isCurrent, ensureLoaded, seekTo]) + if (!isCurrent) { + await ensureLoaded({ startPositionMs: ms, autoPlay: isPlaying }) + } else { + await seekTo(ms) + } + } catch (e) { console.log('[PLAYER] Seek error:', e.message) } + }, [trackDescriptor, sliderDurationMs, isCurrent, ensureLoaded, isPlaying, seekTo]) const handleLyricsSeek = async (timeS) => { - const ms = timeS * 1000 + const ms = Math.max(0, timeS * 1000) try { if (!isCurrent) await ensureLoaded({ startPositionMs: ms, autoPlay: true }) else { await seekTo(ms) if (!isPlaying) await resume() } - } catch (e) { console.log('[PLAYER ERROR] LyricsSeek:', e.message) } + } catch (e) { console.log('[PLAYER] Lyrics Seek error:', e.message) } } - // --- UI PARTS (Options / Share) --- - const sharePayload = useMemo(() => createMusicSharePayload({ projectId, title, artist }), [artist, projectId, title]) - const handleOpenOptions = () => { + const sharePayload = createMusicSharePayload({ projectId, title, artist }) SheetManager.show('MusicOptions', { payload: { projectId, title, ownerId: project?.userId || owner?.id || null, @@ -241,7 +257,7 @@ const MusicDetails = ({ route }) => { })} > - {/* HEADER SECTION */} + {/* INFOS MEDIA */} {coverUrl && ( { disabled={!isCurrent} style={{ ...size({ size: 32 }), alignItems: 'center', justifyContent: 'center', opacity: isCurrent ? 1 : 0.4 }} > - + { if (!projectId || !ensureAuthenticated(currentUID)) return - const next = !fav - setFav(next) + const next = !fav; setFav(next) projectsRef.doc(projectId).update({ likedBy: next ? arrayUnion(currentUID) : arrayRemove(currentUID), }).catch(() => setFav(!next)) @@ -292,7 +307,7 @@ const MusicDetails = ({ route }) => { { ))} ) : ( - Chargement des paroles... + + {songUrl ? "Chargement des paroles..." : "Aucune parole disponible"} + )} diff --git a/src/screens/Production/PlaybackDownload.js b/src/screens/Production/PlaybackDownload.js index c97f367..857ac11 100644 --- a/src/screens/Production/PlaybackDownload.js +++ b/src/screens/Production/PlaybackDownload.js @@ -199,8 +199,6 @@ const PlaybackDownload = ({ route }) => { } }, [projectForDownload?.playbackUrl]) - - const resolveAudioUrl = useCallback(() => { if (typeof projectForDownload?.songUrl === 'string' && projectForDownload.songUrl.trim()) { return projectForDownload.songUrl.trim() @@ -217,61 +215,32 @@ const PlaybackDownload = ({ route }) => { return null }, [projectForDownload]) - const handleDownloadUri = useCallback(async () => { - if (isPublishing || isDownloading) return + const processMerge = useCallback(async () => { if (action !== 'playback') { - setTooltip({ - type: 'error', - text: 'Téléchargement indisponible pour cette action', - }) - return + throw new Error('Téléchargement indisponible pour cette action') } - const playbackUrlToDownload = pendingPlaybackUrl || projectForDownload?.playbackUrl || null - if (playbackUrlToDownload) { - setIsDownloading(true) - try { - await triggerDownload(playbackUrlToDownload, projectForDownload?.title) - } finally { - setIsDownloading(false) - } - return - } + const cachedPlaybackUrl = pendingPlaybackUrl || projectForDownload?.playbackUrl || null + if (cachedPlaybackUrl) return cachedPlaybackUrl if (!projectForDownload?.id) { - setTooltip({ - type: 'error', - text: 'Projet introuvable pour ce playback', - }) - return + throw new Error('Projet introuvable pour ce playback') } if (!uri) { - setTooltip({ - type: 'error', - text: 'Aucune vidéo trouvée pour ce playback', - }) - return + throw new Error('Aucune vidéo trouvée pour ce playback') } if (!currentUID) { - setTooltip({ - type: 'error', - text: 'Utilisateur non authentifié', - }) - return + throw new Error('Utilisateur non authentifié') } const audioUrl = resolveAudioUrl() if (!audioUrl) { - setTooltip({ - type: 'error', - text: 'Aucune piste audio disponible pour ce projet', - }) - return + throw new Error('Aucune piste audio disponible pour ce projet') } - console.log('[PlaybackDownload] handleDownloadUri playback', { + console.log('[PlaybackDownload] processMerge playback', { projectId: projectForDownload.id, uri, currentUID, @@ -279,7 +248,6 @@ const PlaybackDownload = ({ route }) => { let tempSourcePath = null try { - setIsDownloading(true) setIsLoading(true) const { sourcePath, videoUrl } = await uploadSourceRecording({ uri, @@ -289,7 +257,7 @@ const PlaybackDownload = ({ route }) => { tempSourcePath = sourcePath const payload = { - projectId: projectForDownload?.id, + projectId: projectForDownload.id, videoUrl, audioUrl, storagePath: `users/${currentUID}/projects/${projectForDownload.id}/playback.mp4`, @@ -303,29 +271,12 @@ const PlaybackDownload = ({ route }) => { const resultURI = result?.url || null - if (resultURI) { - setPendingPlaybackUrl(resultURI) - setTooltip({ - type: 'success', - text: 'Playback prêt à télécharger', - }) - await triggerDownload(resultURI, projectForDownload?.title) - if (tempSourcePath) { - try { - await firebase.storage().ref(tempSourcePath).delete() - } catch (cleanupError) { - console.log('[PlaybackDownload] unable to delete temp source', { - message: cleanupError?.message, - code: cleanupError?.code, - }) - } - } - } else { - setTooltip({ - type: 'error', - text: 'Erreur lors de la publication du playback', - }) + if (!resultURI) { + throw new Error('Erreur lors de la publication du playback') } + + setPendingPlaybackUrl(resultURI) + return resultURI } catch (error) { console.log('[PlaybackDownload] error upload playback', { message: error?.message, @@ -333,31 +284,47 @@ const PlaybackDownload = ({ route }) => { name: error?.name, details: error?.details, }) + throw error + } finally { if (tempSourcePath) { try { await firebase.storage().ref(tempSourcePath).delete() - } catch { } + } catch (cleanupError) { + console.log('[PlaybackDownload] unable to delete temp source', { + message: cleanupError?.message, + code: cleanupError?.code, + }) + } } + releaseBlobUrl(uri || null) + setIsLoading(false) + } + }, [action, currentUID, pendingPlaybackUrl, projectForDownload, resolveAudioUrl, setIsLoading, uri]) + + const handleDownloadUri = useCallback(async () => { + if (isPublishing || isDownloading) return + try { + setIsDownloading(true) + const resultURI = await processMerge() + setTooltip({ + type: 'success', + text: 'Playback prêt à télécharger', + }) + await triggerDownload(resultURI, projectForDownload?.title) + } catch (error) { setTooltip({ type: 'error', text: String(error?.message || 'Erreur lors de la publication du playback'), }) } finally { - releaseBlobUrl(uri || null) setIsDownloading(false) - setIsLoading(false) } }, [ - action, - currentUID, isDownloading, isPublishing, - pendingPlaybackUrl, + processMerge, projectForDownload, - resolveAudioUrl, - setIsLoading, setTooltip, - uri, ]) const startPlaybackDownloadCheckout = useCallback( @@ -397,34 +364,21 @@ const PlaybackDownload = ({ route }) => { ] ) - const handleDownloadPress = useCallback(() => { - if (isDownloading || isCheckoutLaunching) { - return + const handleDownloadPress = async () => { + if (!canDownloadPlayback) return startPlaybackDownloadCheckout() + + if (isDownloading || isPublishing) return + + try { + setIsDownloading(true) + const url = await processMerge() + await triggerDownload(url, projectForDownload?.title) + } catch (err) { + setTooltip({ type: 'error', text: err.message }) + } finally { + setIsDownloading(false) } - if (canDownloadPlayback) { - handleDownloadUri() - return - } - if (downloadPaymentPending) { - Alert.alert( - 'Paiement en attente', - "Le paiement n'est pas encore confirmé. Si tu as déjà payé, patiente quelques instants.", - [ - { text: 'Attendre', style: 'cancel' }, - { text: 'Relancer le paiement', onPress: () => startPlaybackDownloadCheckout({ force: true }) }, - ] - ) - return - } - startPlaybackDownloadCheckout() - }, [ - canDownloadPlayback, - downloadPaymentPending, - handleDownloadUri, - isCheckoutLaunching, - isDownloading, - startPlaybackDownloadCheckout, - ]) + } useEffect(() => { if (!downloadPaymentPending || !hasPaidPlaybackDownload || isDownloading) { @@ -435,135 +389,29 @@ const PlaybackDownload = ({ route }) => { }, [downloadPaymentPending, handleDownloadUri, hasPaidPlaybackDownload, isDownloading]) const handlePublish = async () => { - if (isPublishing) { - console.log('[PlaybackDownload] publish blocked: already publishing') - return - } - if (!hasAcceptedPublication) { - console.log('[PlaybackDownload] publish blocked: publication not accepted') - setTooltip({ - type: 'error', - text: 'Confirme la diffusion de ton playback sur Musicland avant de publier', - }) - return - } - if (!projectForDownload?.id) { - console.log('[PlaybackDownload] publish: missing project id, navigating directly', { - projectForDownload: projectForDownload?.id, - }) - setTooltip({ - type: 'success', - text: publishSuccessMessage, - }) - navigate(Routes.PublishYoutube) - return - } - let tempSourcePath = null - let playbackUrlToSave = pendingPlaybackUrl || projectForDownload?.playbackUrl || null - const audioUrl = resolveAudioUrl() - if (!audioUrl && !playbackUrlToSave) { - console.log('[PlaybackDownload] publish blocked: missing audioUrl and playbackUrl', { - audioUrl, - playbackUrlToSave, - }) - setTooltip({ - type: 'error', - text: 'Aucune piste audio disponible pour ce projet', - }) - return - } - if (!currentUID) { - console.log('[PlaybackDownload] publish blocked: missing currentUID') - setTooltip({ - type: 'error', - text: 'Utilisateur non authentifié', - }) - return - } + if (!hasAcceptedPublication) return + + if (isPublishing) return try { - console.log('[PlaybackDownload] publish start', { - projectId: projectForDownload?.id, - hasPendingPlaybackUrl: Boolean(pendingPlaybackUrl), - hasPlaybackUrl: Boolean(projectForDownload?.playbackUrl), - hasAudioUrl: Boolean(audioUrl), - hasUri: Boolean(uri), - }) setIsPublishing(true) - setIsLoading(true) - - if (!playbackUrlToSave) { - console.log('[PlaybackDownload] publish: no playback url, uploading source') - const { sourcePath, videoUrl } = await uploadSourceRecording({ - uri, - uid: currentUID, - projectId: projectForDownload.id, - }) - tempSourcePath = sourcePath - - const payload = { - projectId: projectForDownload?.id, - videoUrl, - audioUrl, - storagePath: `users/${currentUID}/projects/${projectForDownload.id}/playback.mp4`, - } - - console.log('[PlaybackDownload] publish: calling merge', payload) - - const { data: result } = await callMergeVideoAndAudio(payload) - - playbackUrlToSave = result?.url || null - if (!playbackUrlToSave) { - console.log('[PlaybackDownload] publish: merge result missing url', result) - throw new Error('merge_failed') - } - setPendingPlaybackUrl(playbackUrlToSave) - } - - console.log('[PlaybackDownload] publish: saving playback url', { - projectId: projectForDownload.id, - playbackUrlToSave, - }) + const url = await processMerge() await projectsRef.doc(projectForDownload.id).set( { - playbackUrl: playbackUrlToSave, + playbackUrl: url, updatedAt: serverTimestamp(), }, { merge: true } ) - setTooltip({ - type: 'success', - text: publishSuccessMessage, - }) - console.log('[PlaybackDownload] publish: navigate to PublishYoutube', { - projectId: projectForDownload.id, - }) navigate(Routes.PublishYoutube, { projectId: projectForDownload.id }) - } catch (error) { - console.log('[PlaybackDownload] publish error', { - message: error?.message, - code: error?.code, - name: error?.name, - details: error?.details, - }) + } catch (err) { setTooltip({ type: 'error', - text: String(error?.message || 'Publication impossible'), + text: String(err?.message || 'Erreur de publication'), }) } finally { - if (tempSourcePath) { - try { - await firebase.storage().ref(tempSourcePath).delete() - } catch (cleanupError) { - console.log('[PlaybackDownload] unable to delete temp source', { - message: cleanupError?.message, - code: cleanupError?.code, - }) - } - } setIsPublishing(false) - setIsLoading(false) } } return (