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 \