feat: merge audio

This commit is contained in:
2026-03-03 15:24:38 +01:00
parent 4755c0d8e2
commit 372b59660f
2 changed files with 125 additions and 260 deletions
+64 -47
View File
@@ -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 }) => {
})}
>
<View style={{ flex: 1, paddingTop: 20, paddingBottom: gutters * 2 }}>
{/* HEADER SECTION */}
{/* INFOS MEDIA */}
<View style={{ gap: 28 }}>
{coverUrl && (
<ExpoImage
@@ -265,13 +281,12 @@ const MusicDetails = ({ route }) => {
disabled={!isCurrent}
style={{ ...size({ size: 32 }), alignItems: 'center', justifyContent: 'center', opacity: isCurrent ? 1 : 0.4 }}
>
<Feather name="repeat" size={24} color={loopEnabled ? Palette.primary : Palette.white} />
<Feather name="repeat" size={24} color={isCurrent && isLooping ? Palette.primary : Palette.white} />
</PressableScale>
<PressableScale
onPress={async () => {
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 }) => {
<View style={{ paddingTop: 22 }}>
<ProgressSlider
positionMs={positionMs}
durationMs={durationMs || 0}
durationMs={sliderDurationMs}
isPlaying={isPlaying}
onSeek={handleSliderSeek}
onPause={pause}
@@ -344,7 +359,9 @@ const MusicDetails = ({ route }) => {
))}
</ScrollView>
) : (
<Text style={{ color: 'gray', textAlign: 'center', marginTop: 20 }}>Chargement des paroles...</Text>
<Text style={{ color: 'rgba(255,255,255,0.4)', textAlign: 'center', marginTop: 40, fontFamily: FONT_FAMILY.InterRegular }}>
{songUrl ? "Chargement des paroles..." : "Aucune parole disponible"}
</Text>
)}
</View>
</View>
+61 -213
View File
@@ -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 (