diff --git a/functions/src/cover.js b/functions/src/cover.js index db70171..70b013a 100644 --- a/functions/src/cover.js +++ b/functions/src/cover.js @@ -58,7 +58,7 @@ async function buildCoverWithLogo(backgroundUrl, targetPath) { }); return `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent( - targetPath, + targetPath )}?alt=media&token=${token}`; } @@ -131,7 +131,7 @@ async function performCoverGeneration(project) { coverStatus: "GENERATED", updatedAt: admin.firestore.FieldValue.serverTimestamp(), }, - { merge: true }, + { merge: true } ); logger.info("✅ [Cover] Saved", { @@ -280,7 +280,7 @@ exports.onTaskCreateGenerateCover = onDocumentCreated( }); await event.data.ref.set( { status: "ERROR", error: error?.message || "Erreur" }, - { merge: true }, + { merge: true } ); await refList.projects.doc(projectId).update({ coverStatus: "ERROR", @@ -318,5 +318,5 @@ exports.onTaskCreateGenerateCover = onDocumentCreated( } } } - }, + } ); diff --git a/src/components/SongStructureDragDrop.js b/src/components/SongStructureDragDrop.js index e024d29..49aba8d 100644 --- a/src/components/SongStructureDragDrop.js +++ b/src/components/SongStructureDragDrop.js @@ -1,4 +1,11 @@ -import { View, Text, ScrollView, StyleSheet, Dimensions } from "react-native"; +import { + View, + Text, + ScrollView, + StyleSheet, + Dimensions, + TouchableOpacity, +} from "react-native"; import React, { useEffect, useMemo, useState } from "react"; import Animated, { runOnJS, @@ -50,7 +57,6 @@ const SongStructureDragDrop = ({ sourceItems, initialSelected, onChange }) => { [sourceItems], ); - const [leftItems, setLeftItems] = useState(normalizedSource); const [rightItems, setRightItems] = useState([]); // Compare arrays by item id and order to avoid unnecessary state churn @@ -67,13 +73,16 @@ const SongStructureDragDrop = ({ sourceItems, initialSelected, onChange }) => { useEffect(() => { const selected = Array.isArray(initialSelected) ? initialSelected : []; const selectedSet = new Set(selected); - const right = normalizedSource.filter((i) => selectedSet.has(i.id)); - const left = normalizedSource.filter((i) => !selectedSet.has(i.id)); - - setLeftItems((prev) => (sameById(prev, left) ? prev : left)); - setRightItems((prev) => (sameById(prev, right) ? prev : right)); + const nextRight = normalizedSource.filter((i) => selectedSet.has(i.id)); + setRightItems((prev) => (sameById(prev, nextRight) ? prev : nextRight)); }, [normalizedSource, initialSelected]); + const leftItems = useMemo(() => { + if (!rightItems || rightItems.length === 0) return normalizedSource; + const rightIds = new Set(rightItems.map((item) => item.id)); + return normalizedSource.filter((item) => !rightIds.has(item.id)); + }, [normalizedSource, rightItems]); + const draggingItem = useSharedValue(null); const translateX = useSharedValue(0); const translateY = useSharedValue(0); @@ -83,15 +92,22 @@ const SongStructureDragDrop = ({ sourceItems, initialSelected, onChange }) => { }; const moveItem = (itemId) => { - setLeftItems((items) => { - const found = items.find((i) => i.id === itemId); - if (!found) return items; - setRightItems((r) => { - const next = [...r, found]; - onChange?.(next.map((i) => i.value ?? i.label)); - return next; - }); - return items.filter((i) => i.id !== itemId); + const item = normalizedSource.find((i) => i.id === itemId); + if (!item) return; + setRightItems((current) => { + if (current.some((i) => i.id === itemId)) return current; + const next = [...current, item]; + onChange?.(next.map((i) => i.value ?? i.label)); + return next; + }); + }; + + const removeItem = (itemId) => { + setRightItems((current) => { + if (!current.some((i) => i.id === itemId)) return current; + const next = current.filter((i) => i.id !== itemId); + onChange?.(next.map((i) => i.value ?? i.label)); + return next; }); }; @@ -133,10 +149,19 @@ const SongStructureDragDrop = ({ sourceItems, initialSelected, onChange }) => { Right + Appuyez pour retirer un élément {rightItems.map((item) => ( - + removeItem(item.id)} + activeOpacity={0.7} + accessibilityRole="button" + accessibilityLabel={`Retirer ${item.label ?? String(item)}`} + > {item.label ?? String(item)} - + Retirer + ))} @@ -169,6 +194,24 @@ const styles = StyleSheet.create({ marginVertical: 5, borderRadius: 10, }, + selectedItem: { + backgroundColor: "#bdf", + padding: 15, + marginVertical: 5, + borderRadius: 10, + alignItems: "center", + }, + helpText: { + fontSize: 12, + color: "#666", + marginBottom: 8, + textAlign: "center", + }, + removeHint: { + fontSize: 12, + color: "#555", + marginTop: 4, + }, text: { color: "#333", textAlign: "center", diff --git a/src/hooks/useSharedAudioPlayer.js b/src/hooks/useSharedAudioPlayer.js index 9f2e37a..54ea474 100644 --- a/src/hooks/useSharedAudioPlayer.js +++ b/src/hooks/useSharedAudioPlayer.js @@ -65,6 +65,7 @@ const useSharedAudioPlayer = (source, options = null) => { pause, resume, seekTo, + ensureLoaded, isPlaying, positionMs, durationMs, @@ -84,10 +85,16 @@ const useSharedAudioPlayer = (source, options = null) => { }; }, [isPlaying, positionMs, durationMs]); - const controlsRef = useRef({ play, pause, resume, seekTo }); + const controlsRef = useRef({ + play, + pause, + resume, + seekTo, + ensureLoaded, + }); useEffect(() => { - controlsRef.current = { play, pause, resume, seekTo }; - }, [play, pause, resume, seekTo]); + controlsRef.current = { play, pause, resume, seekTo, ensureLoaded }; + }, [play, pause, resume, seekTo, ensureLoaded]); const playerRef = useRef(null); @@ -138,6 +145,15 @@ const useSharedAudioPlayer = (source, options = null) => { }, }); + player.load = async ({ startPositionMs = 0 } = {}) => { + if (typeof controlsRef.current.ensureLoaded !== "function") return; + const target = Math.max(0, Number(startPositionMs) || 0); + await controlsRef.current.ensureLoaded({ + startPositionMs: target, + autoPlay: false, + }); + }; + playerRef.current = player; } }, [descriptor]); diff --git a/src/hooks/useTrackController.js b/src/hooks/useTrackController.js index e948ef7..75701e6 100644 --- a/src/hooks/useTrackController.js +++ b/src/hooks/useTrackController.js @@ -1,4 +1,4 @@ -import { useCallback, useMemo } from "react"; +import { useCallback, useMemo, useRef } from "react"; import usePlayer from "./usePlayer"; const useTrackController = (descriptor) => { @@ -19,10 +19,38 @@ const useTrackController = (descriptor) => { return descriptor; }, [descriptor]); + const lastKnownRef = useRef({ + descriptorId: null, + positionMs: 0, + durationMs: 0, + }); + const trackId = normalizedDescriptor?.id || null; const isCurrent = trackId ? currentTrack?.id === trackId : false; - const effectivePositionMs = isCurrent ? positionMs : 0; - const effectiveDurationMs = isCurrent ? durationMs : 0; + const descriptorId = normalizedDescriptor?.id ?? null; + + if (lastKnownRef.current.descriptorId !== descriptorId) { + lastKnownRef.current = { + descriptorId, + positionMs: 0, + durationMs: 0, + }; + } + + if (isCurrent) { + lastKnownRef.current = { + descriptorId, + positionMs, + durationMs, + }; + } + + const effectivePositionMs = isCurrent + ? positionMs + : lastKnownRef.current.positionMs; + const effectiveDurationMs = isCurrent + ? durationMs + : lastKnownRef.current.durationMs; const effectiveIsPlaying = isCurrent && isPlaying; const ensureLoaded = useCallback( @@ -140,4 +168,3 @@ const useTrackController = (descriptor) => { }; export default useTrackController; - diff --git a/src/screens/Studio/SongReady.js b/src/screens/Studio/SongReady.js index 4fbec42..f84506a 100644 --- a/src/screens/Studio/SongReady.js +++ b/src/screens/Studio/SongReady.js @@ -2,7 +2,7 @@ import { useFocusEffect } from "@react-navigation/native"; import { BlurView } from "expo-blur"; import React, { useCallback, useEffect, useRef, useState } from "react"; -import { Image, Platform, Pressable, Text, View } from "react-native"; +import { Image, Pressable, Text, View } from "react-native"; import { responsiveHeight } from "react-native-responsive-dimensions"; import { background, icons } from "../../assets"; import alert from "../../components/Alert"; @@ -35,6 +35,9 @@ const SongReady = () => { 0: { pos: 0, dur: 0 }, 1: { pos: 0, dur: 0 }, }); + + console.log("progress info", JSON.stringify(progressInfo, null, 2)); + const player0 = useSharedAudioPlayer( musicUrls[0] ? { uri: musicUrls[0] } : undefined, { @@ -66,6 +69,10 @@ const SongReady = () => { } ); const wasPlayingBeforeSeek = useRef({ 0: false, 1: false }); + const prefetchedRef = useRef({ + 0: { url: null, done: false }, + 1: { url: null, done: false }, + }); // Sync URLs from provider's selectedProject useEffect(() => { @@ -75,6 +82,100 @@ const SongReady = () => { setMusicUrls(urls); }, [selectedProject?.musicUrls]); + const firstUrl = musicUrls[0] ?? null; + const secondUrl = musicUrls[1] ?? null; + + useEffect(() => { + let isCancelled = false; + + const waitForDuration = async (index) => { + const getDurationSeconds = () => { + const targetPlayer = index === 0 ? player0 : player1; + if (!targetPlayer) return 0; + const raw = Number(targetPlayer.duration || 0); + return Number.isFinite(raw) ? raw : 0; + }; + + const start = Date.now(); + while (!isCancelled) { + const durationSeconds = getDurationSeconds(); + if (durationSeconds > 0) { + const durationMs = Math.round(durationSeconds * 1000); + setProgressInfo((prev) => { + const current = prev?.[index] || { pos: 0, dur: 0 }; + if (Math.abs(current.dur - durationMs) < 5) return prev; + return { + ...prev, + [index]: { + ...current, + dur: durationMs, + }, + }; + }); + return true; + } + if (Date.now() - start > 6000) { + break; + } + await new Promise((resolve) => setTimeout(resolve, 150)); + } + return false; + }; + + const prefetch = async () => { + const entries = [ + [0, firstUrl, player0], + [1, secondUrl, player1], + ]; + + for (const [idx, url, player] of entries) { + const cacheEntry = prefetchedRef.current[idx]; + + if (!url || !player?.load) { + if (cacheEntry) { + cacheEntry.url = url ?? null; + cacheEntry.done = false; + } + continue; + } + + if (cacheEntry && cacheEntry.url === url && cacheEntry.done) { + continue; + } + + if (cacheEntry) { + cacheEntry.url = url; + cacheEntry.done = false; + } + + try { + await player.load({ startPositionMs: 0 }); + const resolved = await waitForDuration(idx); + if (cacheEntry && !isCancelled) { + cacheEntry.done = resolved; + } + } catch (err) { + if (cacheEntry) { + cacheEntry.done = false; + } + if (!isCancelled) { + console.log("SongReady preload error", err?.message); + } + } + + if (isCancelled) { + break; + } + } + }; + + prefetch(); + + return () => { + isCancelled = true; + }; + }, [firstUrl, secondUrl, player0, player1]); + // Sync progression depuis les players useEffect(() => { // Ensure we consistently work with milliseconds whatever the platform @@ -264,7 +365,7 @@ const SongReady = () => { ]} > { - const [selected, setSelected] = useState(null); + const [selectedProjectId, setSelectedProjectId] = useState(null); const { userProjects: projects, selectProject } = useUser(); + const selectedProject = useMemo(() => { + if (!Array.isArray(projects) || !projects.length) return null; + return projects.find((p) => p?.id === selectedProjectId) ?? null; + }, [projects, selectedProjectId]); + return ( { {projects.map((p) => { const couplet = p?.lyrics?.couplet || ""; const preview = couplet.split("\n").slice(0, 2).join(" "); - const isSelected = selected === p; + const isSelected = selectedProjectId === p?.id; const createdAt = p?.createdAt?.toDate ? p.createdAt.toDate() : p?.createdAt @@ -54,13 +59,20 @@ const Studio = () => { return ( setSelected(p)} + onPress={() => setSelectedProjectId(p?.id ?? null)} style={{ - backgroundColor: "#0F0C1933", + backgroundColor: isSelected + ? "rgba(15, 12, 25, 0.75)" + : "rgba(15, 12, 25, 0.2)", borderRadius: 12, padding: 12, borderWidth: isSelected ? 2 : 0, borderColor: isSelected ? "#F94697" : "transparent", + shadowColor: "rgba(0, 0, 0, 0.55)", + shadowOpacity: isSelected ? 0.6 : 0.35, + shadowRadius: isSelected ? 8 : 4, + shadowOffset: { width: 0, height: 4 }, + elevation: isSelected ? 6 : 1, }} > { { - if (selected.musicStatus === "GENERATING") { + if (selectedProject?.musicStatus === "GENERATING") { alert( "Attention", "La chanson est en cours de génération. Veuillez patienter.", ); } else { - selectProject(selected.id); + if (!selectedProject?.id) return; + selectProject(selectedProject.id); navigate(Routes.Compose); } }} /> - {selected?.musicUrls?.length > 0 && ( + {selectedProject?.musicUrls?.length > 0 && ( <> { marginTop: responsiveHeight(2), }} onPress={() => { - selectProject(selected.id); + if (!selectedProject?.id) return; + selectProject(selectedProject.id); navigate(Routes.SongReady); }} /> - {!selected?.coverUrl && ( + {!selectedProject?.coverUrl && ( { - selectProject(selected.id); + if (!selectedProject?.id) return; + selectProject(selectedProject.id); navigate(Routes.PouchReady); }} /> diff --git a/src/screens/Writing/CreateLyricsWithAi.js b/src/screens/Writing/CreateLyricsWithAi.js index deda536..6d96516 100644 --- a/src/screens/Writing/CreateLyricsWithAi.js +++ b/src/screens/Writing/CreateLyricsWithAi.js @@ -188,6 +188,36 @@ const CreateLyricsWithAi = () => { } }, [structure]); + const sanitizedCustomStructure = useMemo(() => { + if (!Array.isArray(customStructure) || customStructure.length === 0) { + return []; + } + return sanitizeStructureList(customStructure, CUSTOM_SANITIZE_OPTIONS); + }, [customStructure]); + + const sanitizedParsedStructure = useMemo(() => { + if (!Array.isArray(parsedStructure) || parsedStructure.length === 0) { + return []; + } + return sanitizeStructureList(parsedStructure, CUSTOM_SANITIZE_OPTIONS); + }, [parsedStructure]); + + const fallbackProjectStructure = useMemo(() => { + const raw = selectedProject?.config?.structure; + if (!Array.isArray(raw) || raw.length === 0) return []; + return sanitizeStructureList(raw, CUSTOM_SANITIZE_OPTIONS); + }, [selectedProject]); + + const baseStructureForCustomizer = useMemo(() => { + if (sanitizedCustomStructure.length > 0) return sanitizedCustomStructure; + if (sanitizedParsedStructure.length > 0) return sanitizedParsedStructure; + return fallbackProjectStructure; + }, [ + sanitizedCustomStructure, + sanitizedParsedStructure, + fallbackProjectStructure, + ]); + const lyricsConfig = useMemo(() => { const resolvedObjective = shouldUseOtherObjective ? persistedOtherObjective || undefined @@ -195,13 +225,8 @@ const CreateLyricsWithAi = () => { const resolvedStyle = shouldUseOtherStyle ? persistedOtherStyle || undefined : style || undefined; - const sanitizedCustom = - Array.isArray(customStructure) && customStructure.length > 0 - ? sanitizeStructureList(customStructure, CUSTOM_SANITIZE_OPTIONS) - : []; - const sanitizedParsed = Array.isArray(parsedStructure) - ? sanitizeStructureList(parsedStructure) - : []; + const sanitizedCustom = sanitizedCustomStructure; + const sanitizedParsed = sanitizedParsedStructure; return { objective: resolvedObjective, @@ -230,9 +255,9 @@ const CreateLyricsWithAi = () => { emotion, style, audience, - parsedStructure, + sanitizedParsedStructure, rhymes, - customStructure, + sanitizedCustomStructure, ]); const isNextDisabled = React.useMemo(() => { @@ -444,14 +469,7 @@ const CreateLyricsWithAi = () => { }} > diff --git a/src/screens/Writing/CreateLyricsWithAi.web.js b/src/screens/Writing/CreateLyricsWithAi.web.js index 295f953..72db5df 100644 --- a/src/screens/Writing/CreateLyricsWithAi.web.js +++ b/src/screens/Writing/CreateLyricsWithAi.web.js @@ -188,6 +188,36 @@ const CreateLyricsWithAi = () => { } }, [structure]); + const sanitizedCustomStructure = useMemo(() => { + if (!Array.isArray(customStructure) || customStructure.length === 0) { + return []; + } + return sanitizeStructureList(customStructure, CUSTOM_SANITIZE_OPTIONS); + }, [customStructure]); + + const sanitizedParsedStructure = useMemo(() => { + if (!Array.isArray(parsedStructure) || parsedStructure.length === 0) { + return []; + } + return sanitizeStructureList(parsedStructure, CUSTOM_SANITIZE_OPTIONS); + }, [parsedStructure]); + + const fallbackProjectStructure = useMemo(() => { + const raw = selectedProject?.config?.structure; + if (!Array.isArray(raw) || raw.length === 0) return []; + return sanitizeStructureList(raw, CUSTOM_SANITIZE_OPTIONS); + }, [selectedProject]); + + const baseStructureForCustomizer = useMemo(() => { + if (sanitizedCustomStructure.length > 0) return sanitizedCustomStructure; + if (sanitizedParsedStructure.length > 0) return sanitizedParsedStructure; + return fallbackProjectStructure; + }, [ + sanitizedCustomStructure, + sanitizedParsedStructure, + fallbackProjectStructure, + ]); + const progress = useMemo( () => 16 + selectedIndex * 9, [selectedIndex] @@ -200,13 +230,8 @@ const CreateLyricsWithAi = () => { const resolvedStyle = shouldUseOtherStyle ? persistedOtherStyle || undefined : style || undefined; - const sanitizedCustom = - Array.isArray(customStructure) && customStructure.length > 0 - ? sanitizeStructureList(customStructure, CUSTOM_SANITIZE_OPTIONS) - : []; - const sanitizedParsed = Array.isArray(parsedStructure) - ? sanitizeStructureList(parsedStructure) - : []; + const sanitizedCustom = sanitizedCustomStructure; + const sanitizedParsed = sanitizedParsedStructure; return { objective: resolvedObjective, @@ -235,9 +260,9 @@ const CreateLyricsWithAi = () => { emotion, style, audience, - parsedStructure, + sanitizedParsedStructure, rhymes, - customStructure, + sanitizedCustomStructure, ]); const isNextDisabled = React.useMemo(() => { @@ -358,14 +383,7 @@ const CreateLyricsWithAi = () => { key: "customStructure", render: () => ( ), diff --git a/src/screens/Writing/CreatingLyrics.js b/src/screens/Writing/CreatingLyrics.js index f85f0dd..6d0dbae 100644 --- a/src/screens/Writing/CreatingLyrics.js +++ b/src/screens/Writing/CreatingLyrics.js @@ -77,7 +77,10 @@ const CreatingLyrics = ({ active, config, selections }) => { console.log("🚀 [CreatingLyrics] Lancement de la génération", { platform: Platform.OS, }); - const sanitizedStructure = sanitizeStructureList(config?.structure); + const sanitizedStructure = sanitizeStructureList( + config?.structure, + CUSTOM_SANITIZE_OPTIONS + ); const callable = firebase .functions() .httpsCallable("lyrics-generateLyrics"); @@ -168,7 +171,8 @@ const CreatingLyrics = ({ active, config, selections }) => { await setIsLoading(true); console.log("💾 [CreatingLyrics] Sauvegarde automatique des paroles"); const sanitizedStructure = sanitizeStructureList( - result?.structure || config?.structure + result?.structure || config?.structure, + CUSTOM_SANITIZE_OPTIONS ); const baseConfig = config && typeof config === "object" && !Array.isArray(config) diff --git a/youtube.md b/youtube.md index 18d65b3..2bc29a0 100644 --- a/youtube.md +++ b/youtube.md @@ -17,7 +17,7 @@ 1. Ouvre cette URL dans un navigateur (mets à jour `redirect_uri` si besoin) : ``` - https://accounts.google.com/o/oauth2/v2/auth?client_id=YOUR_CLIENT_ID.apps.googleusercontent.com&redirect_uri=http://localhost:8081&response_type=code&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fyoutube.upload&access_type=offline&prompt=consent + https://accounts.google.com/o/oauth2/v2/auth?client_id=305598753437-3mn43cs1phacao2f1ctg1dbur7rct0bt.apps.googleusercontent.com&redirect_uri=http://localhost:8081&response_type=code&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fyoutube.upload&access_type=offline&prompt=consent ``` 2. Connecte-toi au compte YouTube cible et accepte les permissions. 3. Google te redirige vers `http://localhost:8081/?code=...`. Copie la valeur du paramètre `code`. @@ -26,7 +26,7 @@ ```bash curl -X POST https://oauth2.googleapis.com/token \ - -d client_id=YOUR_CLIENT_ID.apps.googleusercontent.com \ + -d client_id=YOUR_CLIENT_ID \ -d client_secret=YOUR_CLIENT_SECRET \ -d code="CODE_RECU" \ -d grant_type=authorization_code \