more fixes web + mobile
This commit is contained in:
@@ -37,6 +37,7 @@ import Providers from "./src/providers";
|
|||||||
import { LogBox } from "react-native";
|
import { LogBox } from "react-native";
|
||||||
import CommentsBottomSheet from "./src/components/bottomsheets/CommentsBottomSheet";
|
import CommentsBottomSheet from "./src/components/bottomsheets/CommentsBottomSheet";
|
||||||
import ShareQrModalContainer from "./src/components/modal/ShareQrModalContainer";
|
import ShareQrModalContainer from "./src/components/modal/ShareQrModalContainer";
|
||||||
|
import AppDownloadBanner from "./src/components/AppDownloadBanner";
|
||||||
import "./src/utils/Sheet";
|
import "./src/utils/Sheet";
|
||||||
|
|
||||||
console.disableYellowBox = true;
|
console.disableYellowBox = true;
|
||||||
@@ -49,8 +50,7 @@ setGlobal(initialGlobalState);
|
|||||||
SplashScreen.preventAutoHideAsync();
|
SplashScreen.preventAutoHideAsync();
|
||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
const [currentUID, setCurrentUID] = useGlobal("currentUID");
|
const [, setCurrentUID] = useGlobal("currentUID");
|
||||||
const [pendingData, setPendingData] = useGlobal("pendingData");
|
|
||||||
const [, setCurrentUserRoles] = useGlobal("currentUserRoles");
|
const [, setCurrentUserRoles] = useGlobal("currentUserRoles");
|
||||||
|
|
||||||
const [appIsReady, setAppIsReady] = useState(false);
|
const [appIsReady, setAppIsReady] = useState(false);
|
||||||
@@ -198,6 +198,7 @@ const App = () => {
|
|||||||
</NavigationContainer>
|
</NavigationContainer>
|
||||||
<CommentsBottomSheet />
|
<CommentsBottomSheet />
|
||||||
<ShareQrModalContainer />
|
<ShareQrModalContainer />
|
||||||
|
<AppDownloadBanner />
|
||||||
{/* </AppLayout> */}
|
{/* </AppLayout> */}
|
||||||
</MenuProvider>
|
</MenuProvider>
|
||||||
</Providers>
|
</Providers>
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ android {
|
|||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 1
|
versionCode 1
|
||||||
versionName "0.0.1"
|
versionName "0.0.2"
|
||||||
}
|
}
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
debug {
|
debug {
|
||||||
|
|||||||
@@ -62,6 +62,45 @@ exports.analyseLyrics = async ({ title = "", lyrics }) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Détection heuristique d'insultes explicites pour durcir la modération avant l'IA
|
||||||
|
const sanitizedLyrics = lyricsText
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize("NFD")
|
||||||
|
.replace(/[\u0300-\u036f]/g, "")
|
||||||
|
.replace(/[^a-z0-9\s]/g, " ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
const escapeRegex = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
const vulgarTerms = [
|
||||||
|
"trou du cul",
|
||||||
|
"trouduc",
|
||||||
|
"encule",
|
||||||
|
"enculer",
|
||||||
|
"connard",
|
||||||
|
"connasse",
|
||||||
|
"con",
|
||||||
|
"fdp",
|
||||||
|
"fils de pute",
|
||||||
|
"pute",
|
||||||
|
"putain",
|
||||||
|
"salope",
|
||||||
|
"salaud",
|
||||||
|
"enfoire",
|
||||||
|
"batard",
|
||||||
|
"ordure",
|
||||||
|
"ta gueule",
|
||||||
|
"nique ta mere",
|
||||||
|
"ntm",
|
||||||
|
];
|
||||||
|
const detectedVulgarities = vulgarTerms.filter((term) => {
|
||||||
|
const pattern = new RegExp(`\\b${escapeRegex(term)}\\b`, "i");
|
||||||
|
return pattern.test(sanitizedLyrics);
|
||||||
|
});
|
||||||
|
const vulgarityHints = detectedVulgarities.length
|
||||||
|
? `<VULGARITES_DETECTEES>${detectedVulgarities.join(", ")}</VULGARITES_DETECTEES>`
|
||||||
|
: "<VULGARITES_DETECTEES>Aucune détectée heuristiquement.</VULGARITES_DETECTEES>";
|
||||||
|
|
||||||
// --- Schéma de sortie (inchangé pour compatibilité) ---
|
// --- Schéma de sortie (inchangé pour compatibilité) ---
|
||||||
const moderationSchema = z.object({
|
const moderationSchema = z.object({
|
||||||
title: z.string().describe("Titre analysé (copie du titre fourni)"),
|
title: z.string().describe("Titre analysé (copie du titre fourni)"),
|
||||||
@@ -101,6 +140,7 @@ Objectif: protéger les utilisateurs en BLOQUANT uniquement les cas réellement
|
|||||||
|
|
||||||
Sois STRICT sur les risques graves, mais NE SOIS PAS PUNITIF envers l'expression artistique.
|
Sois STRICT sur les risques graves, mais NE SOIS PAS PUNITIF envers l'expression artistique.
|
||||||
Évalue TOUJOURS: 1) l'intention, 2) le contexte (récit, citation, dénonciation, rôle/persona), 3) la cible, 4) la probabilité de tort réel.
|
Évalue TOUJOURS: 1) l'intention, 2) le contexte (récit, citation, dénonciation, rôle/persona), 3) la cible, 4) la probabilité de tort réel.
|
||||||
|
Ne laisse PAS passer des insultes vulgaires explicites (connard, trou du cul, enculé, fdp, salope, etc.): au minimum flagged=true avec un score significatif; blocked=true si c'est dirigé ou répété sans distance.
|
||||||
|
|
||||||
Retourne UNIQUEMENT un JSON conforme au schéma. Pas de texte hors JSON.
|
Retourne UNIQUEMENT un JSON conforme au schéma. Pas de texte hors JSON.
|
||||||
`.trim();
|
`.trim();
|
||||||
@@ -135,6 +175,12 @@ Retourne UNIQUEMENT un JSON conforme au schéma. Pas de texte hors JSON.
|
|||||||
<CAS>Si le passage relève de la narration, satire, critique ou roleplay: privilégie flagged=true et blocked=false.</CAS>
|
<CAS>Si le passage relève de la narration, satire, critique ou roleplay: privilégie flagged=true et blocked=false.</CAS>
|
||||||
<CAS>Si le passage constitue une injonction réelle ou un appel clair au tort: blocked=true.</CAS>
|
<CAS>Si le passage constitue une injonction réelle ou un appel clair au tort: blocked=true.</CAS>
|
||||||
</SECTION>
|
</SECTION>
|
||||||
|
<SECTION id="E">
|
||||||
|
<TITRE>INSULTES ET VULGARITES</TITRE>
|
||||||
|
<CAS>Insultes vulgaires explicites (trou du cul, connard, enculé, fdp, salope, ta gueule, nique ta mere, etc.) = flagged=true minimum et score ≥ 0.45.</CAS>
|
||||||
|
<CAS>Si ces insultes visent quelqu'un de manière directe ou répétée, ou incitent à rabaisser/attaquer: blocked=true ou severity high/critical (au moins high si pas certain).</CAS>
|
||||||
|
<CAS>Si c'est de l'auto-dérision ou du langage cru sans cible ni incitation: flagged=true mais blocked=false, score limité.</CAS>
|
||||||
|
</SECTION>
|
||||||
</POLITIQUE>
|
</POLITIQUE>
|
||||||
`.trim();
|
`.trim();
|
||||||
|
|
||||||
@@ -153,9 +199,15 @@ ${lyricsText}
|
|||||||
<REGLE>"blocked" = true UNIQUEMENT pour les cas graves listés dans la section A.</REGLE>
|
<REGLE>"blocked" = true UNIQUEMENT pour les cas graves listés dans la section A.</REGLE>
|
||||||
<REGLE>Si le contenu relève du récit, de la critique, de la satire ou d'une mise en contexte artistique, laisse blocked=false (flagged=true si nécessaire).</REGLE>
|
<REGLE>Si le contenu relève du récit, de la critique, de la satire ou d'une mise en contexte artistique, laisse blocked=false (flagged=true si nécessaire).</REGLE>
|
||||||
<REGLE>Fournis des "excerpts" courts en citant exactement les passages sensibles.</REGLE>
|
<REGLE>Fournis des "excerpts" courts en citant exactement les passages sensibles.</REGLE>
|
||||||
|
<REGLE>Si des insultes vulgaires explicites sont présentes, flagged=true au minimum (score >=0.45) et blocked=true si elles visent clairement quelqu'un.</REGLE>
|
||||||
</DIRECTIVES>
|
</DIRECTIVES>
|
||||||
|
|
||||||
${decisionPolicy}
|
${decisionPolicy}
|
||||||
|
|
||||||
|
<INDICES_SUPPLEMENTAIRES>
|
||||||
|
${vulgarityHints}
|
||||||
|
<REGLE_VULGARITES>Si la liste ci-dessus n'est pas vide, considère ces termes comme signaux forts de harcèlement verbal: flagged=true au minimum et score ajusté en conséquence.</REGLE_VULGARITES>
|
||||||
|
</INDICES_SUPPLEMENTAIRES>
|
||||||
`.trim();
|
`.trim();
|
||||||
|
|
||||||
const { output } = await genkit({
|
const { output } = await genkit({
|
||||||
@@ -167,6 +219,30 @@ ${decisionPolicy}
|
|||||||
output: { schema: moderationSchema },
|
output: { schema: moderationSchema },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (output && detectedVulgarities.length) {
|
||||||
|
output.flagged = true;
|
||||||
|
const bumpScore = detectedVulgarities.length >= 3 ? 0.65 : 0.5;
|
||||||
|
const baseScore = Number.isFinite(output.score) ? output.score : 0;
|
||||||
|
output.score = Math.min(1, Math.max(baseScore, bumpScore));
|
||||||
|
|
||||||
|
const reasons = Array.isArray(output.reasons) ? output.reasons : [];
|
||||||
|
const reason = `Vulgarités détectées (${detectedVulgarities.join(", ")})`;
|
||||||
|
if (!reasons.includes(reason)) reasons.push(reason);
|
||||||
|
output.reasons = reasons;
|
||||||
|
|
||||||
|
const excerpts = Array.isArray(output.excerpts) ? output.excerpts : [];
|
||||||
|
const slots = Math.max(0, 10 - excerpts.length);
|
||||||
|
if (slots > 0) {
|
||||||
|
const severity = detectedVulgarities.length > 2 ? "high" : "medium";
|
||||||
|
const newExcerpts = detectedVulgarities.slice(0, slots).map((term) => ({
|
||||||
|
quote: term,
|
||||||
|
category: "harassment",
|
||||||
|
severity,
|
||||||
|
}));
|
||||||
|
output.excerpts = [...excerpts, ...newExcerpts];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return output;
|
return output;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+156
-47
@@ -11,9 +11,14 @@ const crypto = require("node:crypto");
|
|||||||
const ffmpeg = require("fluent-ffmpeg");
|
const ffmpeg = require("fluent-ffmpeg");
|
||||||
const ffmpegInstaller = require("@ffmpeg-installer/ffmpeg");
|
const ffmpegInstaller = require("@ffmpeg-installer/ffmpeg");
|
||||||
const { Buffer } = require("node:buffer");
|
const { Buffer } = require("node:buffer");
|
||||||
|
const { FieldValue } = require("firebase-admin/firestore");
|
||||||
|
|
||||||
if (!admin.apps.length) admin.initializeApp();
|
if (!admin.apps.length) admin.initializeApp();
|
||||||
ffmpeg.setFfmpegPath(ffmpegInstaller.path);
|
ffmpeg.setFfmpegPath(ffmpegInstaller.path);
|
||||||
|
|
||||||
|
const db = admin.firestore();
|
||||||
|
const PLAYBACK_CODEC_TAG = "h264-v1";
|
||||||
|
|
||||||
async function downloadToFile(url, destPath) {
|
async function downloadToFile(url, destPath) {
|
||||||
if (!/^https?:\/\//i.test(url || "")) {
|
if (!/^https?:\/\//i.test(url || "")) {
|
||||||
throw new HttpsError("invalid-argument", `URL non supportée: ${url}`);
|
throw new HttpsError("invalid-argument", `URL non supportée: ${url}`);
|
||||||
@@ -23,6 +28,9 @@ async function downloadToFile(url, destPath) {
|
|||||||
return res.headers?.["content-type"] || "";
|
return res.headers?.["content-type"] || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SCALE_FILTER =
|
||||||
|
"scale='trunc(min(1920,iw)/2)*2':'trunc(min(1920,ih)/2)*2':force_original_aspect_ratio=decrease";
|
||||||
|
|
||||||
async function muxAudioIntoVideo({ videoPath, audioPath, outPath }) {
|
async function muxAudioIntoVideo({ videoPath, audioPath, outPath }) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
ffmpeg()
|
ffmpeg()
|
||||||
@@ -33,8 +41,21 @@ async function muxAudioIntoVideo({ videoPath, audioPath, outPath }) {
|
|||||||
"0:v:0", // garder la 1re piste vidéo de l'entrée 0
|
"0:v:0", // garder la 1re piste vidéo de l'entrée 0
|
||||||
"-map",
|
"-map",
|
||||||
"1:a:0", // prendre la 1re piste audio de l'entrée 1
|
"1:a:0", // prendre la 1re piste audio de l'entrée 1
|
||||||
|
// Force une sortie H264 1080p max pour compatibilité totale iOS (les WebM VP8/9 posaient problème)
|
||||||
|
"-vf",
|
||||||
|
SCALE_FILTER,
|
||||||
"-c:v",
|
"-c:v",
|
||||||
"copy", // ne pas réencoder la vidéo
|
"libx264",
|
||||||
|
"-preset",
|
||||||
|
"veryfast",
|
||||||
|
"-crf",
|
||||||
|
"22",
|
||||||
|
"-pix_fmt",
|
||||||
|
"yuv420p",
|
||||||
|
"-profile:v",
|
||||||
|
"high",
|
||||||
|
"-level:v",
|
||||||
|
"4.1",
|
||||||
"-c:a",
|
"-c:a",
|
||||||
"aac",
|
"aac",
|
||||||
"-b:a",
|
"-b:a",
|
||||||
@@ -42,6 +63,8 @@ async function muxAudioIntoVideo({ videoPath, audioPath, outPath }) {
|
|||||||
"-movflags",
|
"-movflags",
|
||||||
"+faststart",
|
"+faststart",
|
||||||
"-shortest", // couper à la plus courte des 2 sources
|
"-shortest", // couper à la plus courte des 2 sources
|
||||||
|
"-tag:v",
|
||||||
|
"avc1",
|
||||||
])
|
])
|
||||||
.on("error", reject)
|
.on("error", reject)
|
||||||
.on("end", resolve)
|
.on("end", resolve)
|
||||||
@@ -49,6 +72,67 @@ async function muxAudioIntoVideo({ videoPath, audioPath, outPath }) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function uploadPlaybackAsset({ videoUrl, audioUrl, storagePath }) {
|
||||||
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "merge-"));
|
||||||
|
const videoPath = path.join(tmpDir, "video.mp4");
|
||||||
|
const audioPath = path.join(tmpDir, "audio.mp3");
|
||||||
|
const outPath = path.join(tmpDir, "output.mp4");
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.info("[merge] téléchargement des sources", { videoUrl, audioUrl });
|
||||||
|
|
||||||
|
await downloadToFile(videoUrl, videoPath);
|
||||||
|
await downloadToFile(audioUrl, audioPath);
|
||||||
|
|
||||||
|
logger.info("[merge] transcodage/mux ffmpeg");
|
||||||
|
await muxAudioIntoVideo({ videoPath, audioPath, outPath });
|
||||||
|
|
||||||
|
const bucket = admin.storage().bucket();
|
||||||
|
const downloadToken = crypto.randomUUID();
|
||||||
|
|
||||||
|
await bucket.upload(outPath, {
|
||||||
|
destination: storagePath,
|
||||||
|
metadata: {
|
||||||
|
contentType: "video/mp4",
|
||||||
|
cacheControl: "public,max-age=86400",
|
||||||
|
metadata: { firebaseStorageDownloadTokens: downloadToken },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const fileUrl = `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(
|
||||||
|
storagePath
|
||||||
|
)}?alt=media&token=${downloadToken}`;
|
||||||
|
|
||||||
|
logger.info("[merge] upload terminé", { storagePath });
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
url: fileUrl,
|
||||||
|
contentType: "video/mp4",
|
||||||
|
storagePath,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
logger.error("[merge] échec", { error: err?.message || String(err) });
|
||||||
|
if (err instanceof HttpsError) throw err;
|
||||||
|
throw new HttpsError("internal", err?.message || "Fusion échouée");
|
||||||
|
} finally {
|
||||||
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markPlaybackCompatibility({ projectId, initiatorUid = null }) {
|
||||||
|
if (!projectId) return;
|
||||||
|
const docRef = db.collection("projects").doc(projectId);
|
||||||
|
await docRef.set(
|
||||||
|
{
|
||||||
|
"playbackCompatibility.codec": PLAYBACK_CODEC_TAG,
|
||||||
|
"playbackCompatibility.migratedAt": FieldValue.serverTimestamp(),
|
||||||
|
"playbackCompatibility.migratedBy": initiatorUid,
|
||||||
|
},
|
||||||
|
{ merge: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
exports.mergeVideoAndAudio = onCall(
|
exports.mergeVideoAndAudio = onCall(
|
||||||
{ timeoutSeconds: 540, memory: "1GiB" },
|
{ timeoutSeconds: 540, memory: "1GiB" },
|
||||||
async ({ data = {}, auth }) => {
|
async ({ data = {}, auth }) => {
|
||||||
@@ -56,7 +140,7 @@ exports.mergeVideoAndAudio = onCall(
|
|||||||
if (!uid)
|
if (!uid)
|
||||||
throw new HttpsError("unauthenticated", "Authentification requise");
|
throw new HttpsError("unauthenticated", "Authentification requise");
|
||||||
|
|
||||||
const { videoUrl, audioUrl, storagePath } = data || {};
|
const { videoUrl, audioUrl, storagePath, projectId } = data || {};
|
||||||
|
|
||||||
if (!videoUrl || !audioUrl || !storagePath) {
|
if (!videoUrl || !audioUrl || !storagePath) {
|
||||||
throw new HttpsError(
|
throw new HttpsError(
|
||||||
@@ -65,7 +149,6 @@ exports.mergeVideoAndAudio = onCall(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// sécurité simple: forcer dans le dossier de l'utilisateur si tu veux
|
|
||||||
const expectedPrefix = `users/${uid}/`;
|
const expectedPrefix = `users/${uid}/`;
|
||||||
if (!storagePath.startsWith(expectedPrefix)) {
|
if (!storagePath.startsWith(expectedPrefix)) {
|
||||||
throw new HttpsError(
|
throw new HttpsError(
|
||||||
@@ -74,50 +157,76 @@ exports.mergeVideoAndAudio = onCall(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "merge-"));
|
const result = await uploadPlaybackAsset({ videoUrl, audioUrl, storagePath });
|
||||||
const videoPath = path.join(tmpDir, "video.mp4");
|
if (projectId) {
|
||||||
const audioPath = path.join(tmpDir, "audio.mp3");
|
await markPlaybackCompatibility({ projectId, initiatorUid: uid });
|
||||||
const outPath = path.join(tmpDir, "output.mp4");
|
|
||||||
|
|
||||||
try {
|
|
||||||
logger.info("[merge] téléchargement des sources", { videoUrl, audioUrl });
|
|
||||||
|
|
||||||
await downloadToFile(videoUrl, videoPath);
|
|
||||||
await downloadToFile(audioUrl, audioPath);
|
|
||||||
|
|
||||||
logger.info("[merge] fusion ffmpeg (mux)");
|
|
||||||
await muxAudioIntoVideo({ videoPath, audioPath, outPath });
|
|
||||||
|
|
||||||
const bucket = admin.storage().bucket();
|
|
||||||
const downloadToken = crypto.randomUUID();
|
|
||||||
|
|
||||||
await bucket.upload(outPath, {
|
|
||||||
destination: storagePath,
|
|
||||||
metadata: {
|
|
||||||
contentType: "video/mp4",
|
|
||||||
cacheControl: "public,max-age=86400",
|
|
||||||
metadata: { firebaseStorageDownloadTokens: downloadToken },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const fileUrl = `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(
|
|
||||||
storagePath
|
|
||||||
)}?alt=media&token=${downloadToken}`;
|
|
||||||
|
|
||||||
logger.info("[merge] upload terminé", { storagePath });
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
url: fileUrl,
|
|
||||||
contentType: "video/mp4",
|
|
||||||
storagePath,
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
logger.error("[merge] échec", { error: err?.message || String(err) });
|
|
||||||
if (err instanceof HttpsError) throw err;
|
|
||||||
throw new HttpsError("internal", err?.message || "Fusion échouée");
|
|
||||||
} finally {
|
|
||||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
||||||
}
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
exports.reencodePlayback = onCall(
|
||||||
|
{ timeoutSeconds: 540, memory: "1GiB" },
|
||||||
|
async ({ data = {}, auth }) => {
|
||||||
|
const uid = auth?.uid;
|
||||||
|
if (!uid)
|
||||||
|
throw new HttpsError("unauthenticated", "Authentification requise");
|
||||||
|
|
||||||
|
const projectId = data?.projectId;
|
||||||
|
if (!projectId) {
|
||||||
|
throw new HttpsError(
|
||||||
|
"invalid-argument",
|
||||||
|
"Requis: { projectId } pour relancer le transcodage"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("[reencodePlayback] request received", {
|
||||||
|
projectId,
|
||||||
|
initiator: uid,
|
||||||
|
});
|
||||||
|
const projectRef = db.collection("projects").doc(projectId);
|
||||||
|
const projectSnap = await projectRef.get();
|
||||||
|
if (!projectSnap.exists) {
|
||||||
|
logger.warn("[reencodePlayback] project not found", {
|
||||||
|
projectId,
|
||||||
|
initiator: uid,
|
||||||
|
});
|
||||||
|
throw new HttpsError("not-found", "Projet introuvable");
|
||||||
|
}
|
||||||
|
const project = projectSnap.data() || {};
|
||||||
|
const videoUrl = project.playbackUrl;
|
||||||
|
const audioUrl = project.songUrl;
|
||||||
|
const ownerId = project.userId;
|
||||||
|
|
||||||
|
if (!videoUrl || !audioUrl || !ownerId) {
|
||||||
|
logger.warn("[reencodePlayback] missing fields", {
|
||||||
|
projectId,
|
||||||
|
hasPlaybackUrl: !!videoUrl,
|
||||||
|
hasSongUrl: !!audioUrl,
|
||||||
|
ownerId,
|
||||||
|
});
|
||||||
|
throw new HttpsError(
|
||||||
|
"failed-precondition",
|
||||||
|
"playbackUrl, songUrl ou userId manquant"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const storagePath = `users/${ownerId}/projects/${projectId}/playback.mp4`;
|
||||||
|
const result = await uploadPlaybackAsset({ videoUrl, audioUrl, storagePath });
|
||||||
|
|
||||||
|
await projectRef.set(
|
||||||
|
{
|
||||||
|
playbackUrl: result.url,
|
||||||
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
|
},
|
||||||
|
{ merge: true }
|
||||||
|
);
|
||||||
|
await markPlaybackCompatibility({ projectId, initiatorUid: uid });
|
||||||
|
logger.info("[reencodePlayback] success", {
|
||||||
|
projectId,
|
||||||
|
initiator: uid,
|
||||||
|
storagePath,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 9.8 MiB After Width: | Height: | Size: 7.5 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 4.2 MiB After Width: | Height: | Size: 543 KiB |
@@ -0,0 +1,242 @@
|
|||||||
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
Image,
|
||||||
|
Linking,
|
||||||
|
Platform,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
|
||||||
|
import { icons } from "../assets";
|
||||||
|
import { appleAppStoreUrl, googlePlayStoreUrl } from "../data";
|
||||||
|
import useLayoutType from "../hooks/useLayoutType";
|
||||||
|
import { Palette, gutters } from "../styles";
|
||||||
|
import { FONT_FAMILY } from "../styles/Fonts";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "appDownloadBanner:dismissed";
|
||||||
|
|
||||||
|
const AppDownloadBanner = () => {
|
||||||
|
const { isMobileWeb } = useLayoutType();
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
const [hasHydrated, setHasHydrated] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
|
||||||
|
if (!isMobileWeb) {
|
||||||
|
setIsVisible(false);
|
||||||
|
setHasHydrated(false);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncStorage.getItem(STORAGE_KEY)
|
||||||
|
.then((value) => {
|
||||||
|
if (!mounted) return;
|
||||||
|
setIsVisible(value !== "hidden");
|
||||||
|
setHasHydrated(true);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!mounted) return;
|
||||||
|
setIsVisible(true);
|
||||||
|
setHasHydrated(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
};
|
||||||
|
}, [isMobileWeb]);
|
||||||
|
|
||||||
|
const handleDismiss = useCallback(() => {
|
||||||
|
setIsVisible(false);
|
||||||
|
AsyncStorage.setItem(STORAGE_KEY, "hidden").catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openLink = useCallback((url) => {
|
||||||
|
if (typeof url !== "string") return;
|
||||||
|
const target = url.trim();
|
||||||
|
if (!target) return;
|
||||||
|
|
||||||
|
if (Platform.OS === "web") {
|
||||||
|
try {
|
||||||
|
window.open(target, "_blank", "noopener,noreferrer");
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("AppDownloadBanner: failed to open link in new tab", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Linking.openURL(target).catch((error) => {
|
||||||
|
console.warn("AppDownloadBanner: failed to open store link", error);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleOpenStore = useCallback(
|
||||||
|
(store) => {
|
||||||
|
if (store === "ios") {
|
||||||
|
openLink(appleAppStoreUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (store === "android") {
|
||||||
|
openLink(googlePlayStoreUrl);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[openLink],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isMobileWeb || !isVisible || !hasHydrated) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container} pointerEvents="box-none">
|
||||||
|
<View style={styles.banner}>
|
||||||
|
<View style={styles.headerRow}>
|
||||||
|
<View style={styles.titleRow}>
|
||||||
|
<View style={styles.logoWrapper}>
|
||||||
|
<Image source={icons.musicLandLogo} style={styles.logo} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.titleContent}>
|
||||||
|
<Text style={styles.title}>Télécharge l'app MusicLand</Text>
|
||||||
|
<Text style={styles.subtitle}>
|
||||||
|
Pour une expérience mobile plus fluide, utilise l'application
|
||||||
|
native et retrouve toutes les fonctionnalités.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Fermer l'invitation à télécharger l'application"
|
||||||
|
hitSlop={{ top: 8, right: 8, bottom: 8, left: 8 }}
|
||||||
|
onPress={handleDismiss}
|
||||||
|
style={styles.closeBtn}
|
||||||
|
>
|
||||||
|
<Text style={styles.closeText}>×</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
<View style={styles.actions}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.cta, styles.appStoreCta]}
|
||||||
|
onPress={() => handleOpenStore("ios")}
|
||||||
|
>
|
||||||
|
<Text style={styles.ctaText}>App Store</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.cta, styles.playStoreCta]}
|
||||||
|
onPress={() => handleOpenStore("android")}
|
||||||
|
>
|
||||||
|
<Text style={styles.ctaText}>Google Play</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
position: "fixed",
|
||||||
|
bottom: gutters,
|
||||||
|
left: gutters,
|
||||||
|
right: gutters,
|
||||||
|
alignItems: "center",
|
||||||
|
zIndex: 80,
|
||||||
|
},
|
||||||
|
banner: {
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 520,
|
||||||
|
borderRadius: 18,
|
||||||
|
paddingVertical: 14,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
backgroundColor: "rgba(12, 10, 16, 0.95)",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: Palette.ultraLightWhite,
|
||||||
|
shadowColor: Palette.black,
|
||||||
|
shadowOpacity: 0.3,
|
||||||
|
shadowRadius: 16,
|
||||||
|
shadowOffset: { width: 0, height: 10 },
|
||||||
|
elevation: 10,
|
||||||
|
},
|
||||||
|
headerRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
titleRow: {
|
||||||
|
flex: 1,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
logoWrapper: {
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: "rgba(255, 255, 255, 0.06)",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: Palette.ultraLightWhite,
|
||||||
|
},
|
||||||
|
logo: {
|
||||||
|
width: "80%",
|
||||||
|
height: "80%",
|
||||||
|
resizeMode: "contain",
|
||||||
|
},
|
||||||
|
titleContent: {
|
||||||
|
flex: 1,
|
||||||
|
marginLeft: 12,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: Palette.white,
|
||||||
|
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: Palette.gray,
|
||||||
|
fontFamily: FONT_FAMILY.InterRegular,
|
||||||
|
lineHeight: 18,
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
closeBtn: {
|
||||||
|
marginLeft: 10,
|
||||||
|
},
|
||||||
|
closeText: {
|
||||||
|
color: Palette.white,
|
||||||
|
fontSize: 22,
|
||||||
|
lineHeight: 22,
|
||||||
|
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
},
|
||||||
|
cta: {
|
||||||
|
flex: 1,
|
||||||
|
height: 46,
|
||||||
|
borderRadius: 12,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: Palette.ultraLightWhite,
|
||||||
|
},
|
||||||
|
appStoreCta: {
|
||||||
|
backgroundColor: Palette.transparentWhite,
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
playStoreCta: {
|
||||||
|
backgroundColor: Palette.primary,
|
||||||
|
borderColor: Palette.primary,
|
||||||
|
marginLeft: 8,
|
||||||
|
},
|
||||||
|
ctaText: {
|
||||||
|
fontSize: 15,
|
||||||
|
color: Palette.white,
|
||||||
|
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default AppDownloadBanner;
|
||||||
@@ -9,6 +9,8 @@ import { Portal } from "@gorhom/portal";
|
|||||||
// - url?: string | number (require), source of the video. Defaults to videos.test
|
// - url?: string | number (require), source of the video. Defaults to videos.test
|
||||||
// - visible?: boolean, when false returns null
|
// - visible?: boolean, when false returns null
|
||||||
// - onClose: () => void, called when user skips or when video ends
|
// - onClose: () => void, called when user skips or when video ends
|
||||||
|
const CLOSE_THRESHOLD_SECONDS = 0.35;
|
||||||
|
|
||||||
const FullscreenIntroVideo = ({ url, visible = true, onClose }) => {
|
const FullscreenIntroVideo = ({ url, visible = true, onClose }) => {
|
||||||
const source = url
|
const source = url
|
||||||
? typeof url === "string"
|
? typeof url === "string"
|
||||||
@@ -61,7 +63,19 @@ const FullscreenIntroVideo = ({ url, visible = true, onClose }) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const remaining = player.duration - currentTime;
|
const remaining = player.duration - currentTime;
|
||||||
if (Number.isFinite(remaining) && remaining <= 0.1) {
|
if (Number.isFinite(remaining) && remaining <= CLOSE_THRESHOLD_SECONDS) {
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const playingChangeSub = player.addListener?.(
|
||||||
|
"playingChange",
|
||||||
|
({ isPlaying } = {}) => {
|
||||||
|
if (isPlaying || !player?.duration || hasClosedRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const remaining = player.duration - (player.currentTime ?? 0);
|
||||||
|
if (Number.isFinite(remaining) && remaining <= CLOSE_THRESHOLD_SECONDS) {
|
||||||
handleClose();
|
handleClose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,6 +84,7 @@ const FullscreenIntroVideo = ({ url, visible = true, onClose }) => {
|
|||||||
try {
|
try {
|
||||||
playToEndSub?.remove?.();
|
playToEndSub?.remove?.();
|
||||||
timeUpdateSub?.remove?.();
|
timeUpdateSub?.remove?.();
|
||||||
|
playingChangeSub?.remove?.();
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
};
|
};
|
||||||
}, [handleClose, player, visible]);
|
}, [handleClose, player, visible]);
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import { Asset } from "expo-asset";
|
|||||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { Pressable, Text, View } from "react-native";
|
import { Pressable, Text, View } from "react-native";
|
||||||
|
|
||||||
|
const CLOSE_THRESHOLD_SECONDS = 0.35;
|
||||||
|
const CLOSE_POLL_INTERVAL_MS = 500;
|
||||||
|
|
||||||
const overlayStyle = {
|
const overlayStyle = {
|
||||||
position: "fixed",
|
position: "fixed",
|
||||||
top: 0,
|
top: 0,
|
||||||
@@ -112,8 +115,29 @@ const FullscreenIntroVideo = ({ url, visible = true, onClose }) => {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEnded = handleClose;
|
const shouldClose = () => {
|
||||||
|
if (hasClosedRef.current) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (video.ended) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const remaining = video.duration - video.currentTime;
|
||||||
|
return Number.isFinite(remaining) && remaining <= CLOSE_THRESHOLD_SECONDS;
|
||||||
|
};
|
||||||
|
|
||||||
|
const tryHandleClose = () => {
|
||||||
|
if (shouldClose()) {
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleEnded = tryHandleClose;
|
||||||
|
const handleTimeUpdate = tryHandleClose;
|
||||||
|
const handlePause = tryHandleClose;
|
||||||
video.addEventListener("ended", handleEnded);
|
video.addEventListener("ended", handleEnded);
|
||||||
|
video.addEventListener("timeupdate", handleTimeUpdate);
|
||||||
|
video.addEventListener("pause", handlePause);
|
||||||
|
const pollId = setInterval(tryHandleClose, CLOSE_POLL_INTERVAL_MS);
|
||||||
|
|
||||||
video.currentTime = 0;
|
video.currentTime = 0;
|
||||||
const attemptPlay = () => {
|
const attemptPlay = () => {
|
||||||
@@ -133,6 +157,9 @@ const FullscreenIntroVideo = ({ url, visible = true, onClose }) => {
|
|||||||
return () => {
|
return () => {
|
||||||
video.pause();
|
video.pause();
|
||||||
video.removeEventListener("ended", handleEnded);
|
video.removeEventListener("ended", handleEnded);
|
||||||
|
video.removeEventListener("timeupdate", handleTimeUpdate);
|
||||||
|
video.removeEventListener("pause", handlePause);
|
||||||
|
clearInterval(pollId);
|
||||||
};
|
};
|
||||||
}, [handleClose, muted, uri, visible]);
|
}, [handleClose, muted, uri, visible]);
|
||||||
|
|
||||||
@@ -154,7 +181,6 @@ const FullscreenIntroVideo = ({ url, visible = true, onClose }) => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("video uri", uri);
|
|
||||||
return (
|
return (
|
||||||
<Portal>
|
<Portal>
|
||||||
<View pointerEvents="box-none" style={overlayStyle}>
|
<View pointerEvents="box-none" style={overlayStyle}>
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ const MusicLandHeader = ({
|
|||||||
style,
|
style,
|
||||||
}) => {
|
}) => {
|
||||||
const isWeb = Platform.OS === "web";
|
const isWeb = Platform.OS === "web";
|
||||||
|
const backHitSlop = isWeb
|
||||||
|
? undefined
|
||||||
|
: { top: 16, right: 16, bottom: 16, left: 16 };
|
||||||
const backButtonStyle = isWeb
|
const backButtonStyle = isWeb
|
||||||
? {
|
? {
|
||||||
...Style.containerRow,
|
...Style.containerRow,
|
||||||
@@ -37,7 +40,11 @@ const MusicLandHeader = ({
|
|||||||
return (
|
return (
|
||||||
<View style={{ alignItems: "center", gap: 16, ...style }}>
|
<View style={{ alignItems: "center", gap: 16, ...style }}>
|
||||||
<View style={{ width: "100%", ...Style.containerRow, gap: 16 }}>
|
<View style={{ width: "100%", ...Style.containerRow, gap: 16 }}>
|
||||||
<Pressable style={backButtonStyle} onPress={onPressBack}>
|
<Pressable
|
||||||
|
style={backButtonStyle}
|
||||||
|
onPress={onPressBack}
|
||||||
|
hitSlop={backHitSlop}
|
||||||
|
>
|
||||||
<Image
|
<Image
|
||||||
source={backIconSource}
|
source={backIconSource}
|
||||||
style={backIconStyle}
|
style={backIconStyle}
|
||||||
|
|||||||
@@ -28,7 +28,10 @@ export default ({
|
|||||||
>
|
>
|
||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
{!hideBackButton && (
|
{!hideBackButton && (
|
||||||
<Pressable onPress={() => onBackPressed?.() || goBack()}>
|
<Pressable
|
||||||
|
onPress={() => onBackPressed?.() || goBack()}
|
||||||
|
hitSlop={{ top: 16, right: 16, bottom: 16, left: 16 }}
|
||||||
|
>
|
||||||
<Image
|
<Image
|
||||||
source={icons.chevronDown}
|
source={icons.chevronDown}
|
||||||
style={[
|
style={[
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export const zenvoiceUrl = "https://zenvoice.io/p/65da63e86000344f99035fb6";
|
|||||||
export const firebaseDashboardUrl =
|
export const firebaseDashboardUrl =
|
||||||
"https://console.firebase.google.com/u/0/project";
|
"https://console.firebase.google.com/u/0/project";
|
||||||
export const appleAppStoreUrl = "https://apps.apple.com/app";
|
export const appleAppStoreUrl = "https://apps.apple.com/app";
|
||||||
|
export const googlePlayStoreUrl =
|
||||||
|
"https://play.google.com/store/apps/details?id=com.omedis.musicland";
|
||||||
export const algoliaAppUrl = "https://dashboard.algolia.com/apps";
|
export const algoliaAppUrl = "https://dashboard.algolia.com/apps";
|
||||||
|
|
||||||
export const musiclandProductionBaseUrl =
|
export const musiclandProductionBaseUrl =
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ export function uploadFileToFirebase({
|
|||||||
path,
|
path,
|
||||||
shouldCompress = false,
|
shouldCompress = false,
|
||||||
fileType = "",
|
fileType = "",
|
||||||
|
blob: providedBlob = null,
|
||||||
}) {
|
}) {
|
||||||
return new Promise(async (resolve, reject) => {
|
return new Promise(async (resolve, reject) => {
|
||||||
try {
|
try {
|
||||||
let workingURI = uri;
|
let workingURI = uri;
|
||||||
|
let uploadBlob = providedBlob;
|
||||||
|
|
||||||
// Optionally compress before upload
|
// Optionally compress before upload
|
||||||
try {
|
try {
|
||||||
@@ -30,10 +32,12 @@ export function uploadFileToFirebase({
|
|||||||
workingURI = uri;
|
workingURI = uri;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(workingURI);
|
if (!uploadBlob) {
|
||||||
const blob = await response.blob();
|
const response = await fetch(workingURI);
|
||||||
|
uploadBlob = await response.blob();
|
||||||
|
}
|
||||||
|
|
||||||
const uploadTask = firebase.storage().ref(path).put(blob);
|
const uploadTask = firebase.storage().ref(path).put(uploadBlob);
|
||||||
|
|
||||||
uploadTask.on(
|
uploadTask.on(
|
||||||
Platform.OS === "web"
|
Platform.OS === "web"
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
} from "@stripe/react-stripe-js";
|
} from "@stripe/react-stripe-js";
|
||||||
import { loadStripe } from "@stripe/stripe-js";
|
import { loadStripe } from "@stripe/stripe-js";
|
||||||
import * as WebBrowser from "expo-web-browser";
|
import * as WebBrowser from "expo-web-browser";
|
||||||
|
|
||||||
import Overlay from "../components/Overlay";
|
import Overlay from "../components/Overlay";
|
||||||
import Button from "../components/Button";
|
import Button from "../components/Button";
|
||||||
import useLayoutType from "../hooks/useLayoutType";
|
import useLayoutType from "../hooks/useLayoutType";
|
||||||
@@ -125,45 +124,42 @@ const StripeProvider = ({ children }) => {
|
|||||||
setClientSecret(null);
|
setClientSecret(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const redirectToCheckout = React.useCallback(
|
const redirectToCheckout = React.useCallback(async (checkoutUrl = {}) => {
|
||||||
async (checkoutUrl, { safariWindow } = {}) => {
|
if (!checkoutUrl) {
|
||||||
if (!checkoutUrl) {
|
throw new Error("Session Stripe introuvable.");
|
||||||
throw new Error("Session Stripe introuvable.");
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (isWeb) {
|
if (isWeb) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Impossible d'ouvrir le paiement Stripe sans checkout intégré.",
|
"Impossible d'ouvrir le paiement Stripe sans checkout intégré.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMobileApp = Platform.OS === "ios" || Platform.OS === "android";
|
||||||
|
|
||||||
|
if (isMobileApp) {
|
||||||
|
try {
|
||||||
|
await WebBrowser.openBrowserAsync(checkoutUrl, {
|
||||||
|
enableDefaultShareMenu: false,
|
||||||
|
dismissButtonStyle: "close",
|
||||||
|
presentationStyle:
|
||||||
|
WebBrowser?.WebBrowserPresentationStyle?.PAGE_SHEET,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} catch (webBrowserError) {
|
||||||
|
console.warn(
|
||||||
|
"[StripeProvider] WebBrowser checkout fallback",
|
||||||
|
webBrowserError,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const isMobileApp = Platform.OS === "ios" || Platform.OS === "android";
|
const canOpen = await Linking.canOpenURL(checkoutUrl);
|
||||||
|
if (!canOpen) {
|
||||||
if (isMobileApp) {
|
throw new Error("Impossible d'ouvrir l'URL de paiement.");
|
||||||
try {
|
}
|
||||||
await WebBrowser.openBrowserAsync(checkoutUrl, {
|
await Linking.openURL(checkoutUrl);
|
||||||
enableDefaultShareMenu: false,
|
}, []);
|
||||||
dismissButtonStyle: "close",
|
|
||||||
presentationStyle:
|
|
||||||
WebBrowser?.WebBrowserPresentationStyle?.PAGE_SHEET,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
} catch (webBrowserError) {
|
|
||||||
console.warn(
|
|
||||||
"[StripeProvider] WebBrowser checkout fallback",
|
|
||||||
webBrowserError,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const canOpen = await Linking.canOpenURL(checkoutUrl);
|
|
||||||
if (!canOpen) {
|
|
||||||
throw new Error("Impossible d'ouvrir l'URL de paiement.");
|
|
||||||
}
|
|
||||||
await Linking.openURL(checkoutUrl);
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const runCheckoutSession = React.useCallback(
|
const runCheckoutSession = React.useCallback(
|
||||||
async ({ callableName, payload, logTag, useEmbeddedFlow = false }) => {
|
async ({ callableName, payload, logTag, useEmbeddedFlow = false }) => {
|
||||||
|
|||||||
@@ -729,8 +729,8 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: isWeb ? "center" : "stretch",
|
alignItems: isWeb ? "center" : "stretch",
|
||||||
justifyContent: isWeb ? "center" : "flex-start",
|
justifyContent: isWeb ? "center" : "flex-start",
|
||||||
gap: 12,
|
gap: 12,
|
||||||
marginTop: isWeb ? 24 : 0,
|
marginTop: isWeb ? 12 : 0,
|
||||||
marginBottom: isWeb ? 8 : 16,
|
marginBottom: isWeb ? 4 : 16,
|
||||||
paddingHorizontal: isWeb ? 0 : gutters,
|
paddingHorizontal: isWeb ? 0 : gutters,
|
||||||
zIndex: 10,
|
zIndex: 10,
|
||||||
},
|
},
|
||||||
@@ -786,8 +786,8 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
subtitleWrapper: {
|
subtitleWrapper: {
|
||||||
width: "100%",
|
width: "100%",
|
||||||
marginBottom: 16,
|
marginBottom: isWeb ? 12 : 16,
|
||||||
marginTop: 15,
|
marginTop: isWeb ? 8 : 15,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
},
|
},
|
||||||
subtitleGradient: {
|
subtitleGradient: {
|
||||||
@@ -812,7 +812,7 @@ const styles = StyleSheet.create({
|
|||||||
backgroundColor: Palette.glass,
|
backgroundColor: Palette.glass,
|
||||||
},
|
},
|
||||||
subtitleInnerWeb: {
|
subtitleInnerWeb: {
|
||||||
paddingVertical: 12,
|
paddingVertical: 10,
|
||||||
backgroundColor: Palette.lightPurple,
|
backgroundColor: Palette.lightPurple,
|
||||||
},
|
},
|
||||||
subtitle: {
|
subtitle: {
|
||||||
@@ -828,7 +828,7 @@ const styles = StyleSheet.create({
|
|||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
flexWrap: "wrap",
|
flexWrap: "wrap",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
rowGap: 20,
|
rowGap: isWeb ? 16 : 20,
|
||||||
},
|
},
|
||||||
cardsStack: {
|
cardsStack: {
|
||||||
width: "100%",
|
width: "100%",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Image as ExpoImage } from "expo-image";
|
|||||||
import { FONT_FAMILY } from "../../../styles/Fonts";
|
import { FONT_FAMILY } from "../../../styles/Fonts";
|
||||||
import { Palette } from "../../../styles";
|
import { Palette } from "../../../styles";
|
||||||
import { icons } from "../../../assets";
|
import { icons } from "../../../assets";
|
||||||
|
import { isWeb } from "../../../hooks/useLayoutType.js";
|
||||||
|
|
||||||
const ClubCard = ({ image, onPress, hasActiveSubscription = false }) => {
|
const ClubCard = ({ image, onPress, hasActiveSubscription = false }) => {
|
||||||
const subtitle = hasActiveSubscription
|
const subtitle = hasActiveSubscription
|
||||||
@@ -32,7 +33,7 @@ export default memo(ClubCard);
|
|||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
card: {
|
card: {
|
||||||
marginTop: 28,
|
marginTop: isWeb ? 16 : 28,
|
||||||
borderRadius: 20,
|
borderRadius: 20,
|
||||||
backgroundColor: "#252438",
|
backgroundColor: "#252438",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
|
|||||||
@@ -71,8 +71,8 @@ const StageCard = ({
|
|||||||
style={[
|
style={[
|
||||||
{
|
{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: isMobileVariant ? 190 : "100%",
|
height: 190,
|
||||||
minHeight: isMobileVariant ? 190 : 210,
|
minHeight: 190,
|
||||||
borderRadius: 0,
|
borderRadius: 0,
|
||||||
},
|
},
|
||||||
isMobileVariant
|
isMobileVariant
|
||||||
@@ -92,7 +92,7 @@ const StageCard = ({
|
|||||||
width: "100%",
|
width: "100%",
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
marginBottom: 8,
|
marginBottom: isMobileVariant ? 8 : 6,
|
||||||
},
|
},
|
||||||
isMobileVariant
|
isMobileVariant
|
||||||
? { justifyContent: "center" }
|
? { justifyContent: "center" }
|
||||||
@@ -125,7 +125,7 @@ const StageCard = ({
|
|||||||
const textBlockBaseStyle = {
|
const textBlockBaseStyle = {
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
flexShrink: 1,
|
flexShrink: 1,
|
||||||
paddingVertical: 20,
|
paddingVertical: isMobileVariant ? 20 : 16,
|
||||||
paddingHorizontal: 20,
|
paddingHorizontal: 20,
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignSelf: "center",
|
alignSelf: "center",
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ const MusicDetails = ({ route }) => {
|
|||||||
const [fav, setFav] = useState(false);
|
const [fav, setFav] = useState(false);
|
||||||
const [currentUID] = useGlobal("currentUID");
|
const [currentUID] = useGlobal("currentUID");
|
||||||
const wasPlayingBeforeSeek = useRef(false);
|
const wasPlayingBeforeSeek = useRef(false);
|
||||||
|
const hasCapturedSeekStateRef = useRef(false);
|
||||||
|
const lastSeekTargetMsRef = useRef(null);
|
||||||
const listenedMsRef = useRef(0);
|
const listenedMsRef = useRef(0);
|
||||||
const incrementDoneRef = useRef(false);
|
const incrementDoneRef = useRef(false);
|
||||||
const timerRef = useRef(null);
|
const timerRef = useRef(null);
|
||||||
@@ -330,8 +332,12 @@ const MusicDetails = ({ route }) => {
|
|||||||
|
|
||||||
const handleSliderSeekStart = useCallback(async () => {
|
const handleSliderSeekStart = useCallback(async () => {
|
||||||
if (!trackDescriptor) return;
|
if (!trackDescriptor) return;
|
||||||
try {
|
lastSeekTargetMsRef.current = null;
|
||||||
|
if (!hasCapturedSeekStateRef.current) {
|
||||||
|
hasCapturedSeekStateRef.current = true;
|
||||||
wasPlayingBeforeSeek.current = isTrackPlaying;
|
wasPlayingBeforeSeek.current = isTrackPlaying;
|
||||||
|
}
|
||||||
|
try {
|
||||||
if (!isCurrentTrack) {
|
if (!isCurrentTrack) {
|
||||||
await ensureLoaded({ startPositionMs: positionMs, autoPlay: false });
|
await ensureLoaded({ startPositionMs: positionMs, autoPlay: false });
|
||||||
}
|
}
|
||||||
@@ -355,6 +361,7 @@ const MusicDetails = ({ route }) => {
|
|||||||
const dur = sliderDurationMs || 0;
|
const dur = sliderDurationMs || 0;
|
||||||
if (!trackDescriptor || dur <= 0) return;
|
if (!trackDescriptor || dur <= 0) return;
|
||||||
const targetMs = Math.max(0, Math.floor(dur * ratio));
|
const targetMs = Math.max(0, Math.floor(dur * ratio));
|
||||||
|
lastSeekTargetMsRef.current = targetMs;
|
||||||
try {
|
try {
|
||||||
if (!isCurrentTrack) {
|
if (!isCurrentTrack) {
|
||||||
await ensureLoaded({ startPositionMs: targetMs, autoPlay: false });
|
await ensureLoaded({ startPositionMs: targetMs, autoPlay: false });
|
||||||
@@ -422,11 +429,24 @@ const MusicDetails = ({ route }) => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const handleSliderSeekEnd = useCallback(async () => {
|
const handleSliderSeekEnd = useCallback(async () => {
|
||||||
|
const targetMs =
|
||||||
|
typeof lastSeekTargetMsRef.current === "number"
|
||||||
|
? Math.max(0, lastSeekTargetMsRef.current)
|
||||||
|
: null;
|
||||||
try {
|
try {
|
||||||
if (wasPlayingBeforeSeek.current) {
|
if (wasPlayingBeforeSeek.current) {
|
||||||
if (!isCurrentTrack) {
|
if (!isCurrentTrack) {
|
||||||
await ensureLoaded({ startPositionMs: positionMs, autoPlay: true });
|
await ensureLoaded({
|
||||||
|
startPositionMs:
|
||||||
|
targetMs !== null && Number.isFinite(targetMs)
|
||||||
|
? targetMs
|
||||||
|
: positionMs,
|
||||||
|
autoPlay: true,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
|
if (targetMs !== null && Number.isFinite(targetMs)) {
|
||||||
|
await seekTrackTo(targetMs);
|
||||||
|
}
|
||||||
await resumeTrack();
|
await resumeTrack();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -434,8 +454,10 @@ const MusicDetails = ({ route }) => {
|
|||||||
console.log("MusicDetails seek end error", e?.message);
|
console.log("MusicDetails seek end error", e?.message);
|
||||||
} finally {
|
} finally {
|
||||||
wasPlayingBeforeSeek.current = false;
|
wasPlayingBeforeSeek.current = false;
|
||||||
|
hasCapturedSeekStateRef.current = false;
|
||||||
|
lastSeekTargetMsRef.current = null;
|
||||||
}
|
}
|
||||||
}, [ensureLoaded, isCurrentTrack, positionMs, resumeTrack]);
|
}, [ensureLoaded, isCurrentTrack, positionMs, resumeTrack, seekTrackTo]);
|
||||||
|
|
||||||
const handleSeekBySeconds = useCallback(
|
const handleSeekBySeconds = useCallback(
|
||||||
async (deltaSeconds) => {
|
async (deltaSeconds) => {
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ const Playback = ({ route, navigation }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Page headerType="NONE" backgroundImg={background.playbackBG2}>
|
<Page headerType="NONE" backgroundImg={background.playbackBG2}>
|
||||||
<Image source={ai.john} style={styles.img} resizeMode="contain" />
|
<Image source={ai.theo} style={styles.img} resizeMode="contain" />
|
||||||
<MusicLandHeader
|
<MusicLandHeader
|
||||||
onPressBack={() => navigation.navigate(Routes.Home)}
|
onPressBack={() => navigation.navigate(Routes.Home)}
|
||||||
progress={25}
|
progress={25}
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ const RecordPlayback = ({ route }) => {
|
|||||||
const countdownTimerRef = useRef(null);
|
const countdownTimerRef = useRef(null);
|
||||||
const listenTimerRef = useRef(null);
|
const listenTimerRef = useRef(null);
|
||||||
const checkSongEndRef = useRef(null);
|
const checkSongEndRef = useRef(null);
|
||||||
|
const isPausedRef = useRef(false);
|
||||||
const stopRequestedRef = useRef(false);
|
const stopRequestedRef = useRef(false);
|
||||||
const restartRequestedRef = useRef(false);
|
const restartRequestedRef = useRef(false);
|
||||||
const manualRestartInFlightRef = useRef(false);
|
const manualRestartInFlightRef = useRef(false);
|
||||||
@@ -73,6 +74,7 @@ const RecordPlayback = ({ route }) => {
|
|||||||
const [isPreparing, setIsPreparing] = useState(false);
|
const [isPreparing, setIsPreparing] = useState(false);
|
||||||
const [countdown, setCountdown] = useState(0);
|
const [countdown, setCountdown] = useState(0);
|
||||||
const [isRecording, setIsRecording] = useState(false);
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
|
const [isPaused, setIsPaused] = useState(false);
|
||||||
const [showProgress, setShowProgress] = useState(false);
|
const [showProgress, setShowProgress] = useState(false);
|
||||||
const [progressInfo, setProgressInfo] = useState({ pos: 0, dur: 0 });
|
const [progressInfo, setProgressInfo] = useState({ pos: 0, dur: 0 });
|
||||||
const [cameraFacing, setCameraFacing] = useState("front");
|
const [cameraFacing, setCameraFacing] = useState("front");
|
||||||
@@ -100,6 +102,9 @@ const RecordPlayback = ({ route }) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
latestLoopingValueRef.current = isLooping ?? false;
|
latestLoopingValueRef.current = isLooping ?? false;
|
||||||
}, [isLooping]);
|
}, [isLooping]);
|
||||||
|
useEffect(() => {
|
||||||
|
isPausedRef.current = isPaused;
|
||||||
|
}, [isPaused]);
|
||||||
|
|
||||||
const musicIndex = useMemo(() => {
|
const musicIndex = useMemo(() => {
|
||||||
const i = Number(songIndex);
|
const i = Number(songIndex);
|
||||||
@@ -302,8 +307,13 @@ const RecordPlayback = ({ route }) => {
|
|||||||
|
|
||||||
setIsPreparing(false);
|
setIsPreparing(false);
|
||||||
setIsRecording(false);
|
setIsRecording(false);
|
||||||
|
setIsPaused(false);
|
||||||
|
isPausedRef.current = false;
|
||||||
setShowProgress(false);
|
setShowProgress(false);
|
||||||
setCountdown(0);
|
setCountdown(0);
|
||||||
|
try {
|
||||||
|
await cameraRef.current?.resumePreview?.();
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
if (player) {
|
if (player) {
|
||||||
try {
|
try {
|
||||||
@@ -392,6 +402,8 @@ const RecordPlayback = ({ route }) => {
|
|||||||
preserveSongEndWatcher,
|
preserveSongEndWatcher,
|
||||||
preserveStopRequest,
|
preserveStopRequest,
|
||||||
});
|
});
|
||||||
|
setIsPaused(false);
|
||||||
|
isPausedRef.current = false;
|
||||||
if (!preserveRestartFlag) {
|
if (!preserveRestartFlag) {
|
||||||
restartRequestedRef.current = false;
|
restartRequestedRef.current = false;
|
||||||
manualRestartInFlightRef.current = false;
|
manualRestartInFlightRef.current = false;
|
||||||
@@ -443,6 +455,11 @@ const RecordPlayback = ({ route }) => {
|
|||||||
|
|
||||||
setIsRecording(true);
|
setIsRecording(true);
|
||||||
setShowProgress(true);
|
setShowProgress(true);
|
||||||
|
setIsPaused(false);
|
||||||
|
isPausedRef.current = false;
|
||||||
|
try {
|
||||||
|
await cameraRef.current?.resumePreview?.();
|
||||||
|
} catch (_) {}
|
||||||
playbackStartedRef.current = false;
|
playbackStartedRef.current = false;
|
||||||
progressLogRef.current = { bucket: -1, lastPos: -1, lastDur: -1 };
|
progressLogRef.current = { bucket: -1, lastPos: -1, lastDur: -1 };
|
||||||
log("startRecordingWithMusic", {
|
log("startRecordingWithMusic", {
|
||||||
@@ -566,6 +583,7 @@ const RecordPlayback = ({ route }) => {
|
|||||||
checkSongEndRef.current = setInterval(() => {
|
checkSongEndRef.current = setInterval(() => {
|
||||||
try {
|
try {
|
||||||
if (!player) return;
|
if (!player) return;
|
||||||
|
if (isPausedRef.current) return;
|
||||||
if (stopRequestedRef.current) {
|
if (stopRequestedRef.current) {
|
||||||
const sinceLastRequest = Date.now() - (stopRequestedAtRef.current || 0);
|
const sinceLastRequest = Date.now() - (stopRequestedAtRef.current || 0);
|
||||||
if (sinceLastRequest >= 1200) {
|
if (sinceLastRequest >= 1200) {
|
||||||
@@ -630,6 +648,8 @@ const RecordPlayback = ({ route }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setIsRecording(false);
|
setIsRecording(false);
|
||||||
|
setIsPaused(false);
|
||||||
|
isPausedRef.current = false;
|
||||||
setShowProgress(false);
|
setShowProgress(false);
|
||||||
log("Recording flow completed", { hasVideo: !!video?.uri });
|
log("Recording flow completed", { hasVideo: !!video?.uri });
|
||||||
playbackStartedRef.current = false;
|
playbackStartedRef.current = false;
|
||||||
@@ -683,6 +703,8 @@ const RecordPlayback = ({ route }) => {
|
|||||||
stopRequestedAtRef.current = 0;
|
stopRequestedAtRef.current = 0;
|
||||||
setIsRecording(false);
|
setIsRecording(false);
|
||||||
setIsPreparing(false);
|
setIsPreparing(false);
|
||||||
|
setIsPaused(false);
|
||||||
|
isPausedRef.current = false;
|
||||||
setShowProgress(false);
|
setShowProgress(false);
|
||||||
if (listenTimerRef.current) {
|
if (listenTimerRef.current) {
|
||||||
clearInterval(listenTimerRef.current);
|
clearInterval(listenTimerRef.current);
|
||||||
@@ -786,6 +808,33 @@ const RecordPlayback = ({ route }) => {
|
|||||||
}, [handleBackPress])
|
}, [handleBackPress])
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleTogglePause = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
if (!isRecording) return;
|
||||||
|
if (!isPausedRef.current) {
|
||||||
|
setIsPaused(true);
|
||||||
|
isPausedRef.current = true;
|
||||||
|
stopRequestedRef.current = false;
|
||||||
|
stopRequestedAtRef.current = 0;
|
||||||
|
try {
|
||||||
|
await player?.pause?.();
|
||||||
|
} catch (_) {}
|
||||||
|
try {
|
||||||
|
await cameraRef.current?.pausePreview?.();
|
||||||
|
} catch (_) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsPaused(false);
|
||||||
|
isPausedRef.current = false;
|
||||||
|
try {
|
||||||
|
await cameraRef.current?.resumePreview?.();
|
||||||
|
} catch (_) {}
|
||||||
|
try {
|
||||||
|
await player?.play?.();
|
||||||
|
} catch (_) {}
|
||||||
|
} catch (_) {}
|
||||||
|
}, [isRecording, player]);
|
||||||
|
|
||||||
const handleRestartRecording = async () => {
|
const handleRestartRecording = async () => {
|
||||||
try {
|
try {
|
||||||
const hasRecordingPending = !!activeRecordingPromiseRef.current;
|
const hasRecordingPending = !!activeRecordingPromiseRef.current;
|
||||||
@@ -794,6 +843,11 @@ const RecordPlayback = ({ route }) => {
|
|||||||
isRecording,
|
isRecording,
|
||||||
hasRecordingPending,
|
hasRecordingPending,
|
||||||
});
|
});
|
||||||
|
setIsPaused(false);
|
||||||
|
isPausedRef.current = false;
|
||||||
|
try {
|
||||||
|
await cameraRef.current?.resumePreview?.();
|
||||||
|
} catch (_) {}
|
||||||
if (isPreparing) {
|
if (isPreparing) {
|
||||||
if (hasRecordingPending) {
|
if (hasRecordingPending) {
|
||||||
await startCountdownThenRecord({
|
await startCountdownThenRecord({
|
||||||
@@ -980,6 +1034,34 @@ const RecordPlayback = ({ route }) => {
|
|||||||
<RestartSpinnerIcon />
|
<RestartSpinnerIcon />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
{isRecording && (
|
||||||
|
<TouchableOpacity
|
||||||
|
activeOpacity={0.9}
|
||||||
|
onPress={handleTogglePause}
|
||||||
|
style={{
|
||||||
|
marginTop: 14,
|
||||||
|
paddingVertical: 10,
|
||||||
|
paddingHorizontal: 18,
|
||||||
|
borderRadius: 999,
|
||||||
|
backgroundColor: Palette.white,
|
||||||
|
shadowColor: "#000000",
|
||||||
|
shadowOpacity: 0.12,
|
||||||
|
shadowRadius: 10,
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
elevation: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: Palette.black,
|
||||||
|
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||||
|
fontSize: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isPaused ? "Reprendre" : "Mettre en pause"}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { FONT_FAMILY } from "../../styles/Fonts";
|
|||||||
import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader";
|
import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader";
|
||||||
import { background } from "../../assets";
|
import { background } from "../../assets";
|
||||||
import RestartSpinnerIcon from "../../assets/UI/RestartSpinnerIcon";
|
import RestartSpinnerIcon from "../../assets/UI/RestartSpinnerIcon";
|
||||||
|
import { registerBlobUrl, releaseBlobUrl } from "../../utils/blobUrlCache";
|
||||||
|
|
||||||
const TIME_BEFORE_INCREMENT_MS = 20000;
|
const TIME_BEFORE_INCREMENT_MS = 20000;
|
||||||
const WEB_PREVIEW_WIDTH = 360;
|
const WEB_PREVIEW_WIDTH = 360;
|
||||||
@@ -82,6 +83,7 @@ const RecordPlayback = ({ route }) => {
|
|||||||
const stopRecordingPromiseRef = useRef(null);
|
const stopRecordingPromiseRef = useRef(null);
|
||||||
const stopRecordingResolveRef = useRef(null);
|
const stopRecordingResolveRef = useRef(null);
|
||||||
const recordedUrlRef = useRef(null);
|
const recordedUrlRef = useRef(null);
|
||||||
|
const preserveRecordingForNextScreenRef = useRef(false);
|
||||||
const originalLoopingValueRef = useRef({ hasValue: false, value: false });
|
const originalLoopingValueRef = useRef({ hasValue: false, value: false });
|
||||||
const latestLoopingValueRef = useRef(isLooping ?? false);
|
const latestLoopingValueRef = useRef(isLooping ?? false);
|
||||||
const [mediaReady, setMediaReady] = useState(false);
|
const [mediaReady, setMediaReady] = useState(false);
|
||||||
@@ -91,14 +93,18 @@ const RecordPlayback = ({ route }) => {
|
|||||||
const [countdown, setCountdown] = useState(0);
|
const [countdown, setCountdown] = useState(0);
|
||||||
const [isPreparing, setIsPreparing] = useState(false);
|
const [isPreparing, setIsPreparing] = useState(false);
|
||||||
const [isRecording, setIsRecording] = useState(false);
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
|
const [isPaused, setIsPaused] = useState(false);
|
||||||
const [pos, setPos] = useState(0);
|
const [pos, setPos] = useState(0);
|
||||||
const [dur, setDur] = useState(0);
|
const [dur, setDur] = useState(0);
|
||||||
|
|
||||||
// timers / refs
|
// timers / refs
|
||||||
|
const isPausedRef = useRef(false);
|
||||||
const progressTimerRef = useRef(null);
|
const progressTimerRef = useRef(null);
|
||||||
const listenTimerRef = useRef(null);
|
const listenTimerRef = useRef(null);
|
||||||
const countdownTimerRef = useRef(null);
|
const countdownTimerRef = useRef(null);
|
||||||
const perfStartRef = useRef(null);
|
const perfStartRef = useRef(null);
|
||||||
|
const pausedAtRef = useRef(null);
|
||||||
|
const pausedMsRef = useRef(0);
|
||||||
const listenedMsRef = useRef(0);
|
const listenedMsRef = useRef(0);
|
||||||
const viewsIncrementedRef = useRef(false);
|
const viewsIncrementedRef = useRef(false);
|
||||||
const correctedInitialJumpRef = useRef(false);
|
const correctedInitialJumpRef = useRef(false);
|
||||||
@@ -106,6 +112,9 @@ const RecordPlayback = ({ route }) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
latestLoopingValueRef.current = isLooping ?? false;
|
latestLoopingValueRef.current = isLooping ?? false;
|
||||||
}, [isLooping]);
|
}, [isLooping]);
|
||||||
|
useEffect(() => {
|
||||||
|
isPausedRef.current = isPaused;
|
||||||
|
}, [isPaused]);
|
||||||
|
|
||||||
// Lyrics
|
// Lyrics
|
||||||
const alignedWords = useMemo(() => {
|
const alignedWords = useMemo(() => {
|
||||||
@@ -131,6 +140,10 @@ const RecordPlayback = ({ route }) => {
|
|||||||
const resetUI = () => {
|
const resetUI = () => {
|
||||||
setIsPreparing(false);
|
setIsPreparing(false);
|
||||||
setIsRecording(false);
|
setIsRecording(false);
|
||||||
|
setIsPaused(false);
|
||||||
|
isPausedRef.current = false;
|
||||||
|
pausedAtRef.current = null;
|
||||||
|
pausedMsRef.current = 0;
|
||||||
setCountdown(0);
|
setCountdown(0);
|
||||||
setPos(0);
|
setPos(0);
|
||||||
setDur(0);
|
setDur(0);
|
||||||
@@ -141,12 +154,13 @@ const RecordPlayback = ({ route }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const releaseRecordingUrl = useCallback(() => {
|
const releaseRecordingUrl = useCallback(() => {
|
||||||
if (recordedUrlRef.current) {
|
if (!recordedUrlRef.current) {
|
||||||
try {
|
preserveRecordingForNextScreenRef.current = false;
|
||||||
URL.revokeObjectURL(recordedUrlRef.current);
|
return;
|
||||||
} catch {}
|
|
||||||
recordedUrlRef.current = null;
|
|
||||||
}
|
}
|
||||||
|
releaseBlobUrl(recordedUrlRef.current);
|
||||||
|
recordedUrlRef.current = null;
|
||||||
|
preserveRecordingForNextScreenRef.current = false;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const startRecorder = useCallback(() => {
|
const startRecorder = useCallback(() => {
|
||||||
@@ -203,6 +217,7 @@ const RecordPlayback = ({ route }) => {
|
|||||||
});
|
});
|
||||||
url = URL.createObjectURL(blob);
|
url = URL.createObjectURL(blob);
|
||||||
recordedUrlRef.current = url;
|
recordedUrlRef.current = url;
|
||||||
|
registerBlobUrl(url, blob);
|
||||||
} else {
|
} else {
|
||||||
releaseRecordingUrl();
|
releaseRecordingUrl();
|
||||||
}
|
}
|
||||||
@@ -273,8 +288,10 @@ const RecordPlayback = ({ route }) => {
|
|||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
try {
|
try {
|
||||||
await stopRecorderAndGetUrl();
|
await stopRecorderAndGetUrl();
|
||||||
releaseRecordingUrl();
|
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
if (!preserveRecordingForNextScreenRef.current) {
|
||||||
|
releaseRecordingUrl();
|
||||||
|
}
|
||||||
}, [player, releaseRecordingUrl, stopRecorderAndGetUrl]);
|
}, [player, releaseRecordingUrl, stopRecorderAndGetUrl]);
|
||||||
|
|
||||||
const resetSessionRef = useRef(resetSession);
|
const resetSessionRef = useRef(resetSession);
|
||||||
@@ -410,11 +427,14 @@ const RecordPlayback = ({ route }) => {
|
|||||||
mediaStreamRef.current.getTracks().forEach((track) => track.stop());
|
mediaStreamRef.current.getTracks().forEach((track) => track.stop());
|
||||||
mediaStreamRef.current = null;
|
mediaStreamRef.current = null;
|
||||||
}
|
}
|
||||||
releaseRecordingUrl();
|
if (!preserveRecordingForNextScreenRef.current) {
|
||||||
|
releaseRecordingUrl();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [releaseRecordingUrl]);
|
}, [releaseRecordingUrl, preserveRecordingForNextScreenRef]);
|
||||||
|
|
||||||
const stopAndNavigate = useCallback(async () => {
|
const stopAndNavigate = useCallback(async () => {
|
||||||
|
preserveRecordingForNextScreenRef.current = true;
|
||||||
clearAllTimers();
|
clearAllTimers();
|
||||||
try {
|
try {
|
||||||
await player?.pause?.();
|
await player?.pause?.();
|
||||||
@@ -429,6 +449,10 @@ const RecordPlayback = ({ route }) => {
|
|||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
||||||
setIsRecording(false);
|
setIsRecording(false);
|
||||||
|
setIsPaused(false);
|
||||||
|
isPausedRef.current = false;
|
||||||
|
pausedAtRef.current = null;
|
||||||
|
pausedMsRef.current = 0;
|
||||||
navigate(Routes.RecordedPlayback, { project, videoUri: videoUrl || null });
|
navigate(Routes.RecordedPlayback, { project, videoUri: videoUrl || null });
|
||||||
}, [dur, player, pos, project, stopRecorderAndGetUrl]);
|
}, [dur, player, pos, project, stopRecorderAndGetUrl]);
|
||||||
|
|
||||||
@@ -438,11 +462,19 @@ const RecordPlayback = ({ route }) => {
|
|||||||
progressTimerRef.current = setInterval(async () => {
|
progressTimerRef.current = setInterval(async () => {
|
||||||
const rawDur = toSeconds(player?.duration);
|
const rawDur = toSeconds(player?.duration);
|
||||||
const rawPos = toSeconds(player?.currentTime);
|
const rawPos = toSeconds(player?.currentTime);
|
||||||
|
const pausedSince =
|
||||||
|
pausedAtRef.current != null
|
||||||
|
? Math.max(0, performance.now() - pausedAtRef.current)
|
||||||
|
: 0;
|
||||||
|
const pausedTotal = pausedMsRef.current + pausedSince;
|
||||||
|
|
||||||
// fallback monotone si le player ne donne rien
|
// fallback monotone si le player ne donne rien
|
||||||
const fallback =
|
const fallback =
|
||||||
perfStartRef.current != null
|
perfStartRef.current != null
|
||||||
? Math.max(0, (performance.now() - perfStartRef.current) / 1000)
|
? Math.max(
|
||||||
|
0,
|
||||||
|
(performance.now() - perfStartRef.current - pausedTotal) / 1000
|
||||||
|
)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
let nextDur = rawDur > 0 ? rawDur : dur || 0;
|
let nextDur = rawDur > 0 ? rawDur : dur || 0;
|
||||||
@@ -464,7 +496,7 @@ const RecordPlayback = ({ route }) => {
|
|||||||
|
|
||||||
const timeSinceStart =
|
const timeSinceStart =
|
||||||
perfStartRef.current != null
|
perfStartRef.current != null
|
||||||
? performance.now() - perfStartRef.current
|
? Math.max(0, performance.now() - perfStartRef.current - pausedTotal)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const shouldTrustFallback =
|
const shouldTrustFallback =
|
||||||
@@ -484,6 +516,10 @@ const RecordPlayback = ({ route }) => {
|
|||||||
setDur(nextDur);
|
setDur(nextDur);
|
||||||
setPos(nextPos);
|
setPos(nextPos);
|
||||||
|
|
||||||
|
if (isPausedRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (nextDur > 0 && nextPos >= nextDur - 0.3) {
|
if (nextDur > 0 && nextPos >= nextDur - 0.3) {
|
||||||
void stopAndNavigate();
|
void stopAndNavigate();
|
||||||
}
|
}
|
||||||
@@ -516,6 +552,10 @@ const RecordPlayback = ({ route }) => {
|
|||||||
|
|
||||||
const startPlayback = useCallback(async () => {
|
const startPlayback = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
setIsPaused(false);
|
||||||
|
isPausedRef.current = false;
|
||||||
|
pausedAtRef.current = null;
|
||||||
|
pausedMsRef.current = 0;
|
||||||
await player?.pause?.(); // s'assure qu'on repart propre
|
await player?.pause?.(); // s'assure qu'on repart propre
|
||||||
await player?.seekTo?.(0); // tente un seek d'amorçage
|
await player?.seekTo?.(0); // tente un seek d'amorçage
|
||||||
const recorderStarted = startRecorder();
|
const recorderStarted = startRecorder();
|
||||||
@@ -530,6 +570,9 @@ const RecordPlayback = ({ route }) => {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
setIsRecording(false);
|
setIsRecording(false);
|
||||||
setIsPreparing(false);
|
setIsPreparing(false);
|
||||||
|
setIsPaused(false);
|
||||||
|
isPausedRef.current = false;
|
||||||
|
pausedAtRef.current = null;
|
||||||
const msg = String(e?.message || e || "");
|
const msg = String(e?.message || e || "");
|
||||||
if (/not supported on the simulator/i.test(msg)) {
|
if (/not supported on the simulator/i.test(msg)) {
|
||||||
alert(
|
alert(
|
||||||
@@ -557,6 +600,8 @@ const RecordPlayback = ({ route }) => {
|
|||||||
const startCountdownThenRecord = useCallback(async () => {
|
const startCountdownThenRecord = useCallback(async () => {
|
||||||
if (!songUrl || !canRecord) return;
|
if (!songUrl || !canRecord) return;
|
||||||
await resetSession();
|
await resetSession();
|
||||||
|
setIsPaused(false);
|
||||||
|
isPausedRef.current = false;
|
||||||
setIsPreparing(true);
|
setIsPreparing(true);
|
||||||
setCountdown(5);
|
setCountdown(5);
|
||||||
if (countdownTimerRef.current) clearInterval(countdownTimerRef.current);
|
if (countdownTimerRef.current) clearInterval(countdownTimerRef.current);
|
||||||
@@ -575,6 +620,43 @@ const RecordPlayback = ({ route }) => {
|
|||||||
}, 1000);
|
}, 1000);
|
||||||
}, [songUrl, canRecord, resetSession, startPlayback]);
|
}, [songUrl, canRecord, resetSession, startPlayback]);
|
||||||
|
|
||||||
|
const handleTogglePause = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
if (!isRecording) return;
|
||||||
|
if (!isPausedRef.current) {
|
||||||
|
isPausedRef.current = true;
|
||||||
|
setIsPaused(true);
|
||||||
|
pausedAtRef.current = performance.now();
|
||||||
|
try {
|
||||||
|
await player?.pause?.();
|
||||||
|
} catch (e) {}
|
||||||
|
try {
|
||||||
|
const recorder = mediaRecorderRef.current;
|
||||||
|
if (recorder && recorder.state === "recording") {
|
||||||
|
recorder.pause?.();
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pausedAt = pausedAtRef.current;
|
||||||
|
if (pausedAt != null) {
|
||||||
|
pausedMsRef.current += performance.now() - pausedAt;
|
||||||
|
pausedAtRef.current = null;
|
||||||
|
}
|
||||||
|
isPausedRef.current = false;
|
||||||
|
setIsPaused(false);
|
||||||
|
try {
|
||||||
|
const recorder = mediaRecorderRef.current;
|
||||||
|
if (recorder && recorder.state === "paused") {
|
||||||
|
recorder.resume?.();
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
try {
|
||||||
|
await player?.play?.();
|
||||||
|
} catch (e) {}
|
||||||
|
} catch (e) {}
|
||||||
|
}, [isRecording, player]);
|
||||||
|
|
||||||
const handleRestartRecording = useCallback(async () => {
|
const handleRestartRecording = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
await startCountdownThenRecord();
|
await startCountdownThenRecord();
|
||||||
@@ -586,9 +668,7 @@ const RecordPlayback = ({ route }) => {
|
|||||||
return (
|
return (
|
||||||
<Page
|
<Page
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
width={"100%"}
|
containerStyle={{ backgroundColor: Palette.tran }}
|
||||||
maxWidth={800}
|
|
||||||
containerStyle={{ margin: 0, padding: 0, backgroundColor: Palette.tran }}
|
|
||||||
headerType="NONE"
|
headerType="NONE"
|
||||||
backgroundImg={background.playbackBG2}
|
backgroundImg={background.playbackBG2}
|
||||||
>
|
>
|
||||||
@@ -766,6 +846,34 @@ const RecordPlayback = ({ route }) => {
|
|||||||
<RestartSpinnerIcon />
|
<RestartSpinnerIcon />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
{isRecording && (
|
||||||
|
<TouchableOpacity
|
||||||
|
activeOpacity={0.9}
|
||||||
|
onPress={handleTogglePause}
|
||||||
|
style={{
|
||||||
|
marginTop: 14,
|
||||||
|
paddingVertical: 10,
|
||||||
|
paddingHorizontal: 18,
|
||||||
|
borderRadius: 999,
|
||||||
|
backgroundColor: Palette.white,
|
||||||
|
shadowColor: "#000000",
|
||||||
|
shadowOpacity: 0.12,
|
||||||
|
shadowRadius: 10,
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
elevation: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: Palette.black,
|
||||||
|
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||||
|
fontSize: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isPaused ? "Reprendre" : "Mettre en pause"}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import { gutters } from "../../styles";
|
|||||||
const RecordedPlayback = ({ route }) => {
|
const RecordedPlayback = ({ route }) => {
|
||||||
const { videoUri, project } = route.params || {};
|
const { videoUri, project } = route.params || {};
|
||||||
const songUrl = project?.songUrl || null;
|
const songUrl = project?.songUrl || null;
|
||||||
console.log("video uri is : ", videoUri);
|
|
||||||
const audioPlayer = useSharedAudioPlayer(songUrl ? { uri: songUrl } : undefined, {
|
const audioPlayer = useSharedAudioPlayer(songUrl ? { uri: songUrl } : undefined, {
|
||||||
id: project?.id ? `recorded-${project.id}` : songUrl ? `recorded-${songUrl}` : undefined,
|
id: project?.id ? `recorded-${project.id}` : songUrl ? `recorded-${songUrl}` : undefined,
|
||||||
title: typeof project?.title === "string" ? project.title : "Sans titre",
|
title: typeof project?.title === "string" ? project.title : "Sans titre",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { goBack, navigate } from "../../navigation/NavigationService";
|
|||||||
import { gutters, Palette } from "../../styles";
|
import { gutters, Palette } from "../../styles";
|
||||||
import { isWeb } from "../../hooks/useLayoutType";
|
import { isWeb } from "../../hooks/useLayoutType";
|
||||||
import useSharedAudioPlayer from "../../hooks/useSharedAudioPlayer";
|
import useSharedAudioPlayer from "../../hooks/useSharedAudioPlayer";
|
||||||
|
import { releaseBlobUrl } from "../../utils/blobUrlCache";
|
||||||
|
|
||||||
const fmtSeconds = (s) => {
|
const fmtSeconds = (s) => {
|
||||||
const total = Math.max(0, Math.floor(Number(s || 0)));
|
const total = Math.max(0, Math.floor(Number(s || 0)));
|
||||||
@@ -36,7 +37,6 @@ const WEB_PREVIEW_WIDTH = 360;
|
|||||||
const RecordedPlayback = ({ route }) => {
|
const RecordedPlayback = ({ route }) => {
|
||||||
const { videoUri, project } = route.params || {};
|
const { videoUri, project } = route.params || {};
|
||||||
const songUrl = project?.songUrl || null;
|
const songUrl = project?.songUrl || null;
|
||||||
console.log("video uri is : ", videoUri);
|
|
||||||
// AUDIO PLAYER (expo-audio → seconds)
|
// AUDIO PLAYER (expo-audio → seconds)
|
||||||
const audioPlayer = useSharedAudioPlayer(
|
const audioPlayer = useSharedAudioPlayer(
|
||||||
songUrl ? { uri: songUrl } : undefined,
|
songUrl ? { uri: songUrl } : undefined,
|
||||||
@@ -58,6 +58,7 @@ const RecordedPlayback = ({ route }) => {
|
|||||||
// Optionnel: si tu veux quand même un rendu vidéo sur web si tu as une source
|
// Optionnel: si tu veux quand même un rendu vidéo sur web si tu as une source
|
||||||
const videoElRef = useRef(null);
|
const videoElRef = useRef(null);
|
||||||
const playbackEndedRef = useRef(false);
|
const playbackEndedRef = useRef(false);
|
||||||
|
const shouldPreserveBlobRef = useRef(false);
|
||||||
|
|
||||||
const [progress, setProgress] = useState({
|
const [progress, setProgress] = useState({
|
||||||
posS: 0, // secondes
|
posS: 0, // secondes
|
||||||
@@ -76,6 +77,14 @@ const RecordedPlayback = ({ route }) => {
|
|||||||
} catch {}
|
} catch {}
|
||||||
}, [audioPlayer]);
|
}, [audioPlayer]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (!shouldPreserveBlobRef.current) {
|
||||||
|
releaseBlobUrl(videoUri || null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [videoUri]);
|
||||||
|
|
||||||
// Démarrage / arrêt
|
// Démarrage / arrêt
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
playbackEndedRef.current = false;
|
playbackEndedRef.current = false;
|
||||||
@@ -345,6 +354,7 @@ const RecordedPlayback = ({ route }) => {
|
|||||||
<GradientButton
|
<GradientButton
|
||||||
title="Je valide"
|
title="Je valide"
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
|
shouldPreserveBlobRef.current = true;
|
||||||
navigate(Routes.DownloadSongs, {
|
navigate(Routes.DownloadSongs, {
|
||||||
action: "playback",
|
action: "playback",
|
||||||
uri: videoUri || null, // peut être null sur web
|
uri: videoUri || null, // peut être null sur web
|
||||||
|
|||||||
@@ -8,14 +8,21 @@ import React, {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { Image, Platform, Pressable, Text, View } from "react-native";
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Image,
|
||||||
|
Platform,
|
||||||
|
Pressable,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
import Carousel from "react-native-reanimated-carousel";
|
import Carousel from "react-native-reanimated-carousel";
|
||||||
import { responsiveHeight } from "react-native-responsive-dimensions";
|
import { responsiveHeight } from "react-native-responsive-dimensions";
|
||||||
import { icons, img } from "../../assets";
|
import { icons, img } from "../../assets";
|
||||||
import { openComments } from "../../components/bottomsheets/CommentsBottomSheet";
|
import { openComments } from "../../components/bottomsheets/CommentsBottomSheet";
|
||||||
import KaraokeLyrics from "../../components/KaraokeLyrics";
|
import KaraokeLyrics from "../../components/KaraokeLyrics";
|
||||||
import ProfilePicture from "../../components/ProfilePicture";
|
import ProfilePicture from "../../components/ProfilePicture";
|
||||||
import { projectsRef, usersRef } from "../../config/firebase";
|
import firebase, { projectsRef, usersRef } from "../../config/firebase";
|
||||||
import useDataFromRef from "../../hooks/useDataFromRef";
|
import useDataFromRef from "../../hooks/useDataFromRef";
|
||||||
import { Routes } from "../../navigation";
|
import { Routes } from "../../navigation";
|
||||||
import { navigate } from "../../navigation/NavigationService";
|
import { navigate } from "../../navigation/NavigationService";
|
||||||
@@ -36,14 +43,37 @@ import {
|
|||||||
import { SheetManager } from "react-native-actions-sheet";
|
import { SheetManager } from "react-native-actions-sheet";
|
||||||
import { Feather } from "@expo/vector-icons";
|
import { Feather } from "@expo/vector-icons";
|
||||||
|
|
||||||
|
const PLAYBACK_CODEC_TAG = "h264-v1";
|
||||||
|
|
||||||
const PlaybackItem = ({ item, isActive, userCache, getUserByUid }) => {
|
const PlaybackItem = ({ item, isActive, userCache, getUserByUid }) => {
|
||||||
const { currentUID, followUser, unfollowUser } = useUser() || {};
|
const { currentUID, followUser, unfollowUser } = useUser() || {};
|
||||||
const videoUrl = item?.playbackUrl || null;
|
const baseVideoUrl = item?.playbackUrl || null;
|
||||||
|
const [overrideVideoUrl, setOverrideVideoUrl] = useState(null);
|
||||||
|
const videoUrl = overrideVideoUrl || baseVideoUrl;
|
||||||
const initialLikedBy = getProjectLikes(item, LIKE_TARGET.PLAYBACK);
|
const initialLikedBy = getProjectLikes(item, LIKE_TARGET.PLAYBACK);
|
||||||
const [isLiked, setIsLiked] = useState(
|
const [isLiked, setIsLiked] = useState(
|
||||||
currentUID ? initialLikedBy.includes(currentUID) : false
|
currentUID ? initialLikedBy.includes(currentUID) : false
|
||||||
);
|
);
|
||||||
const [likesCount, setLikesCount] = useState(initialLikedBy.length);
|
const [likesCount, setLikesCount] = useState(initialLikedBy.length);
|
||||||
|
const [repairState, setRepairState] = useState({
|
||||||
|
running: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
const repairAttemptedRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setOverrideVideoUrl(null);
|
||||||
|
repairAttemptedRef.current = false;
|
||||||
|
setRepairState({ running: false, error: null });
|
||||||
|
}, [item?.id, baseVideoUrl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
logPlaybackEvent("state-change", {
|
||||||
|
baseVideoUrl: baseVideoUrl || null,
|
||||||
|
overrideVideoUrl,
|
||||||
|
repairState,
|
||||||
|
});
|
||||||
|
}, [baseVideoUrl, logPlaybackEvent, overrideVideoUrl, repairState]);
|
||||||
|
|
||||||
const commentsCount = useMemo(
|
const commentsCount = useMemo(
|
||||||
() => Number(item?.commentsCount || 0),
|
() => Number(item?.commentsCount || 0),
|
||||||
@@ -122,31 +152,137 @@ const PlaybackItem = ({ item, isActive, userCache, getUserByUid }) => {
|
|||||||
return "";
|
return "";
|
||||||
}, [item?.userName, owner?.artistName, owner?.displayName, owner?.userName]);
|
}, [item?.userName, owner?.artistName, owner?.displayName, owner?.userName]);
|
||||||
|
|
||||||
|
const needsCodecRepair =
|
||||||
|
Platform.OS === "ios" &&
|
||||||
|
!!baseVideoUrl &&
|
||||||
|
item?.playbackCompatibility?.codec !== PLAYBACK_CODEC_TAG;
|
||||||
|
|
||||||
|
const logPlaybackEvent = useCallback(
|
||||||
|
(event, extra = {}) => {
|
||||||
|
if (Platform.OS !== "ios") return;
|
||||||
|
try {
|
||||||
|
console.log("[Playbacks][iOS]", event, {
|
||||||
|
projectId: item?.id,
|
||||||
|
isActive,
|
||||||
|
hasVideoUrl: !!videoUrl,
|
||||||
|
hasOverride: !!overrideVideoUrl,
|
||||||
|
needsCodecRepair,
|
||||||
|
compatibility: item?.playbackCompatibility || null,
|
||||||
|
repairRunning: repairState.running,
|
||||||
|
repairError: repairState.error?.message || null,
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
} catch (loggingError) {
|
||||||
|
// ignore logging errors
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
item?.id,
|
||||||
|
isActive,
|
||||||
|
needsCodecRepair,
|
||||||
|
overrideVideoUrl,
|
||||||
|
repairState.error?.message,
|
||||||
|
repairState.running,
|
||||||
|
videoUrl,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRepair = useCallback(
|
||||||
|
async (force = false) => {
|
||||||
|
if ((!needsCodecRepair && !force) || !item?.id) {
|
||||||
|
logPlaybackEvent("repair-skip", { force });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (repairAttemptedRef.current) {
|
||||||
|
logPlaybackEvent("repair-skip-already-attempted", { force });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
repairAttemptedRef.current = true;
|
||||||
|
setRepairState({ running: true, error: null });
|
||||||
|
logPlaybackEvent("repair-start", { force });
|
||||||
|
try {
|
||||||
|
const callable =
|
||||||
|
firebase.functions().httpsCallable("upload-reencodePlayback");
|
||||||
|
const { data } = await callable({ projectId: item.id });
|
||||||
|
const nextUrl = data?.url || null;
|
||||||
|
if (nextUrl) {
|
||||||
|
setOverrideVideoUrl(nextUrl);
|
||||||
|
}
|
||||||
|
setRepairState({ running: false, error: null });
|
||||||
|
logPlaybackEvent("repair-success", { nextUrlPresent: !!nextUrl });
|
||||||
|
} catch (error) {
|
||||||
|
console.log("[Playbacks] reencode failed", {
|
||||||
|
projectId: item?.id,
|
||||||
|
message: error?.message || String(error || ""),
|
||||||
|
code: error?.code,
|
||||||
|
});
|
||||||
|
setRepairState({
|
||||||
|
running: false,
|
||||||
|
error:
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(String(error || "Playback repair failed")),
|
||||||
|
});
|
||||||
|
logPlaybackEvent("repair-failed", {
|
||||||
|
error: error?.message || String(error || ""),
|
||||||
|
code: error?.code,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[item?.id, logPlaybackEvent, needsCodecRepair]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isActive || !needsCodecRepair) return;
|
||||||
|
handleRepair();
|
||||||
|
}, [isActive, needsCodecRepair, handleRepair]);
|
||||||
|
|
||||||
const videoPlayer = useVideoPlayer(videoUrl || null, (p) => {
|
const videoPlayer = useVideoPlayer(videoUrl || null, (p) => {
|
||||||
p.loop = false;
|
p.loop = false;
|
||||||
p.muted = false;
|
p.muted = false;
|
||||||
p.timeUpdateEventInterval = 0.2;
|
p.timeUpdateEventInterval = 0.2;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (Platform.OS !== "ios") return;
|
||||||
|
if (!videoPlayer?.addListener) return;
|
||||||
|
const sub = videoPlayer.addListener("error", (event) => {
|
||||||
|
console.log("[Playbacks] video error", {
|
||||||
|
projectId: item?.id,
|
||||||
|
error: event?.error || event,
|
||||||
|
});
|
||||||
|
logPlaybackEvent("video-error", { error: event?.error || event });
|
||||||
|
if (!repairAttemptedRef.current) {
|
||||||
|
handleRepair(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
try {
|
||||||
|
sub?.remove?.();
|
||||||
|
} catch (e) {}
|
||||||
|
};
|
||||||
|
}, [videoPlayer, handleRepair, item?.id]);
|
||||||
|
|
||||||
|
const shouldAutoPlay = isActive && !repairState.running;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const toggle = async () => {
|
const toggle = async () => {
|
||||||
try {
|
try {
|
||||||
if (isActive) {
|
if (shouldAutoPlay) {
|
||||||
try {
|
try {
|
||||||
if (videoPlayer) videoPlayer.currentTime = 0;
|
if (videoPlayer) videoPlayer.currentTime = 0;
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
||||||
// Lancer quasi simultanément (éviter await pour limiter le décalage)
|
|
||||||
try {
|
try {
|
||||||
if (videoPlayer) videoPlayer.play();
|
if (videoPlayer) videoPlayer.play();
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
} else {
|
} else if (videoPlayer?.playing) {
|
||||||
if (videoPlayer?.playing) videoPlayer.pause();
|
videoPlayer.pause();
|
||||||
}
|
}
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
};
|
};
|
||||||
toggle();
|
toggle();
|
||||||
}, [isActive, videoPlayer]);
|
}, [shouldAutoPlay, videoPlayer]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -232,6 +368,59 @@ const PlaybackItem = ({ item, isActive, userCache, getUserByUid }) => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{Platform.OS === "ios" && repairState.running && (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
backgroundColor: "#00000066",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ActivityIndicator color={Palette.white} />
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
marginTop: 10,
|
||||||
|
color: Palette.white,
|
||||||
|
fontFamily: FONT_FAMILY.InterMedium,
|
||||||
|
fontSize: 15,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Optimisation vidéo iOS...
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{Platform.OS === "ios" && repairState.error && (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
backgroundColor: "#00000099",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: Palette.white,
|
||||||
|
fontFamily: FONT_FAMILY.InterMedium,
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Impossible de lire cette vidéo. Veuillez réessayer plus tard.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Right side actions */}
|
{/* Right side actions */}
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -28,6 +28,32 @@ import { Palette } from "../../styles";
|
|||||||
import { FONT_FAMILY } from "../../styles/Fonts";
|
import { FONT_FAMILY } from "../../styles/Fonts";
|
||||||
import { size } from "../../styles/Style";
|
import { size } from "../../styles/Style";
|
||||||
import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader";
|
import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader";
|
||||||
|
import { getBlobForUrl, releaseBlobUrl } from "../../utils/blobUrlCache";
|
||||||
|
|
||||||
|
const triggerWebDownload = (url, title) => {
|
||||||
|
if (Platform.OS !== "web") return;
|
||||||
|
if (!url) return;
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
const baseName = (title || "Playback").toString().trim() || "Playback";
|
||||||
|
const sanitized = baseName.replace(/[\\/:*?"<>|]/g, "-");
|
||||||
|
const filename = `${sanitized}.mp4`;
|
||||||
|
try {
|
||||||
|
const anchor = document.createElement("a");
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = filename;
|
||||||
|
anchor.rel = "noopener noreferrer";
|
||||||
|
document.body.appendChild(anchor);
|
||||||
|
anchor.click();
|
||||||
|
document.body.removeChild(anchor);
|
||||||
|
} catch (error) {
|
||||||
|
console.log("[DownloadSongs] web download fallback", {
|
||||||
|
message: error?.message,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
window.open(url, "_blank", "noopener,noreferrer");
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const guessExtension = (inputUri = "") => {
|
const guessExtension = (inputUri = "") => {
|
||||||
const cleaned = inputUri.split("?")[0] || "";
|
const cleaned = inputUri.split("?")[0] || "";
|
||||||
@@ -55,11 +81,17 @@ const uploadSourceRecording = async ({ uri, uid, projectId }) => {
|
|||||||
sourcePath,
|
sourcePath,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const cachedBlob = getBlobForUrl(uri);
|
||||||
|
console.log("[DownloadSongs] uploadSourceRecording blob cache", {
|
||||||
|
hasBlob: Boolean(cachedBlob),
|
||||||
|
});
|
||||||
|
|
||||||
const { resultURI: videoUrl } = await uploadFileToFirebase({
|
const { resultURI: videoUrl } = await uploadFileToFirebase({
|
||||||
uri,
|
uri,
|
||||||
path: sourcePath,
|
path: sourcePath,
|
||||||
shouldCompress: false,
|
shouldCompress: false,
|
||||||
fileType: "VIDEO",
|
fileType: "VIDEO",
|
||||||
|
blob: cachedBlob || undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { sourcePath, videoUrl };
|
return { sourcePath, videoUrl };
|
||||||
@@ -97,6 +129,12 @@ const DownloadSongs = ({ route }) => {
|
|||||||
setIsAfterPlaybackVideoVisible(true);
|
setIsAfterPlaybackVideoVisible(true);
|
||||||
}, [action, afterPlaybackUrl]);
|
}, [action, afterPlaybackUrl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
releaseBlobUrl(uri || null);
|
||||||
|
};
|
||||||
|
}, [uri]);
|
||||||
|
|
||||||
const handleDownloadUri = async () => {
|
const handleDownloadUri = async () => {
|
||||||
if (action === "playback" && project?.id) {
|
if (action === "playback" && project?.id) {
|
||||||
// Publication du playback
|
// Publication du playback
|
||||||
@@ -128,6 +166,7 @@ const DownloadSongs = ({ route }) => {
|
|||||||
.httpsCallable("upload-mergeVideoAndAudio");
|
.httpsCallable("upload-mergeVideoAndAudio");
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
|
projectId: project?.id,
|
||||||
videoUrl,
|
videoUrl,
|
||||||
audioUrl,
|
audioUrl,
|
||||||
storagePath: `users/${currentUID}/projects/${project.id}/playback.mp4`,
|
storagePath: `users/${currentUID}/projects/${project.id}/playback.mp4`,
|
||||||
@@ -156,6 +195,7 @@ const DownloadSongs = ({ route }) => {
|
|||||||
type: "success",
|
type: "success",
|
||||||
text: "Vidéo uploadée",
|
text: "Vidéo uploadée",
|
||||||
});
|
});
|
||||||
|
triggerWebDownload(resultURI, project?.title);
|
||||||
if (tempSourcePath) {
|
if (tempSourcePath) {
|
||||||
try {
|
try {
|
||||||
await firebase.storage().ref(tempSourcePath).delete();
|
await firebase.storage().ref(tempSourcePath).delete();
|
||||||
@@ -191,6 +231,7 @@ const DownloadSongs = ({ route }) => {
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
|
releaseBlobUrl(uri || null);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
// Handle playback download
|
// Handle playback download
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const MUSIC_GENERATION_COIN_COST = 8;
|
|||||||
const CONFIRM_MODAL_MAX_WIDTH = 540;
|
const CONFIRM_MODAL_MAX_WIDTH = 540;
|
||||||
const FUNCTIONS_REGION = "europe-west1";
|
const FUNCTIONS_REGION = "europe-west1";
|
||||||
const VOICE_SECTION_TITLES = ["BASE", "SENSIBILITÉ", "TECHNIQUE"];
|
const VOICE_SECTION_TITLES = ["BASE", "SENSIBILITÉ", "TECHNIQUE"];
|
||||||
|
const OPTIONAL_VOICE_CATEGORIES = new Set(["SENSIBILITÉ", "TECHNIQUE"]);
|
||||||
const FIRST_VOICE_STEP_INDEX = 1;
|
const FIRST_VOICE_STEP_INDEX = 1;
|
||||||
const TOTAL_STEPS = 1 + VOICE_SECTION_TITLES.length + 2; // genre + voices + instruments + rhythm
|
const TOTAL_STEPS = 1 + VOICE_SECTION_TITLES.length + 2; // genre + voices + instruments + rhythm
|
||||||
const LAST_STEP_INDEX = TOTAL_STEPS - 1;
|
const LAST_STEP_INDEX = TOTAL_STEPS - 1;
|
||||||
@@ -78,9 +79,18 @@ const ComposeSong = () => {
|
|||||||
if (selectedIndex === 0) {
|
if (selectedIndex === 0) {
|
||||||
return Array.isArray(genres) && genres.length > 0;
|
return Array.isArray(genres) && genres.length > 0;
|
||||||
}
|
}
|
||||||
if (selectedIndex === FIRST_VOICE_STEP_INDEX) {
|
|
||||||
return !!(voice && typeof voice === "object" && voice.BASE);
|
const voiceStepIndex = selectedIndex - FIRST_VOICE_STEP_INDEX;
|
||||||
|
const isVoiceStep =
|
||||||
|
voiceStepIndex >= 0 && voiceStepIndex < VOICE_SECTION_TITLES.length;
|
||||||
|
if (isVoiceStep) {
|
||||||
|
const category = VOICE_SECTION_TITLES[voiceStepIndex];
|
||||||
|
if (OPTIONAL_VOICE_CATEGORIES.has(category)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return !!(voice && typeof voice === "object" && voice[category]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedIndex === INSTRUMENT_STEP_INDEX) {
|
if (selectedIndex === INSTRUMENT_STEP_INDEX) {
|
||||||
return Array.isArray(instruments) && instruments.length > 0;
|
return Array.isArray(instruments) && instruments.length > 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ const MUSIC_GENERATION_COIN_COST = 8;
|
|||||||
const CONFIRM_MODAL_MAX_WIDTH = 540;
|
const CONFIRM_MODAL_MAX_WIDTH = 540;
|
||||||
const FUNCTIONS_REGION = "europe-west1";
|
const FUNCTIONS_REGION = "europe-west1";
|
||||||
const VOICE_SECTION_TITLES = ["BASE", "SENSIBILITÉ", "TECHNIQUE"];
|
const VOICE_SECTION_TITLES = ["BASE", "SENSIBILITÉ", "TECHNIQUE"];
|
||||||
|
const FIRST_VOICE_STEP_INDEX = 1;
|
||||||
|
const OPTIONAL_VOICE_CATEGORIES = new Set(["SENSIBILITÉ", "TECHNIQUE"]);
|
||||||
|
|
||||||
const ComposeSong = () => {
|
const ComposeSong = () => {
|
||||||
const scrollRef = useRef(null);
|
const scrollRef = useRef(null);
|
||||||
@@ -115,9 +117,18 @@ const ComposeSong = () => {
|
|||||||
if (selectedIndex === 0) {
|
if (selectedIndex === 0) {
|
||||||
return Array.isArray(genres) && genres.length > 0;
|
return Array.isArray(genres) && genres.length > 0;
|
||||||
}
|
}
|
||||||
if (selectedIndex === 1) {
|
|
||||||
return !!(voice && typeof voice === "object" && voice.BASE);
|
const voiceStepIndex = selectedIndex - FIRST_VOICE_STEP_INDEX;
|
||||||
|
const isVoiceStep =
|
||||||
|
voiceStepIndex >= 0 && voiceStepIndex < VOICE_SECTION_TITLES.length;
|
||||||
|
if (isVoiceStep) {
|
||||||
|
const category = VOICE_SECTION_TITLES[voiceStepIndex];
|
||||||
|
if (OPTIONAL_VOICE_CATEGORIES.has(category)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return !!(voice && typeof voice === "object" && voice[category]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedIndex === instrumentStepIndex) {
|
if (selectedIndex === instrumentStepIndex) {
|
||||||
return Array.isArray(instruments) && instruments.length > 0;
|
return Array.isArray(instruments) && instruments.length > 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import ListSelection from "../../components/ListSelection/ListSelection";
|
|||||||
const SECTION_INSTRUCTIONS = {
|
const SECTION_INSTRUCTIONS = {
|
||||||
BASE: "Choisis ta base",
|
BASE: "Choisis ta base",
|
||||||
SENSIBILITE: "Choisis ta sensibilité",
|
SENSIBILITE: "Choisis ta sensibilité",
|
||||||
TECHNIQUE: "Choisis ta technique",
|
TECHNIQUE: "Choisis ta technique (Facultatif)",
|
||||||
};
|
};
|
||||||
|
|
||||||
const normalizeCategory = (value) => {
|
const normalizeCategory = (value) => {
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ const Lyrics = ({ navigation }) => {
|
|||||||
const hasExistingMusicDraft = useMemo(() => {
|
const hasExistingMusicDraft = useMemo(() => {
|
||||||
if (!Array.isArray(selectedProject?.musicUrls)) return false;
|
if (!Array.isArray(selectedProject?.musicUrls)) return false;
|
||||||
return selectedProject.musicUrls.some(
|
return selectedProject.musicUrls.some(
|
||||||
(url) => typeof url === "string" && url.trim()
|
(url) => typeof url === "string" && url.trim(),
|
||||||
);
|
);
|
||||||
}, [selectedProject?.musicUrls]);
|
}, [selectedProject?.musicUrls]);
|
||||||
const [isFocus, setIsFocus] = useState(null);
|
const [isFocus, setIsFocus] = useState(null);
|
||||||
@@ -53,6 +53,28 @@ const Lyrics = ({ navigation }) => {
|
|||||||
const [isSensitiveContentAcknowledged, setIsSensitiveContentAcknowledged] =
|
const [isSensitiveContentAcknowledged, setIsSensitiveContentAcknowledged] =
|
||||||
useState(false);
|
useState(false);
|
||||||
const sensitiveContentResolverRef = useRef(null);
|
const sensitiveContentResolverRef = useRef(null);
|
||||||
|
const updateLayoutAtIndex = useCallback((index, layout) => {
|
||||||
|
if (typeof index !== "number" || !layout) return;
|
||||||
|
setItemsContainerLayout((prev) => {
|
||||||
|
const next = [...prev];
|
||||||
|
next[index] = layout;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
const handleSectionFocus = useCallback(
|
||||||
|
(idx) => {
|
||||||
|
setIsFocus(idx);
|
||||||
|
if (isWeb) return;
|
||||||
|
const targetLayout = itemsContainerLayout[idx + 1];
|
||||||
|
if (typeof targetLayout?.y === "number") {
|
||||||
|
scrollRef?.current?.scrollTo({
|
||||||
|
y: targetLayout.y,
|
||||||
|
animated: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[itemsContainerLayout, isWeb],
|
||||||
|
);
|
||||||
|
|
||||||
const closeSensitiveContentModal = useCallback((result) => {
|
const closeSensitiveContentModal = useCallback((result) => {
|
||||||
setSensitiveContentModal((prev) => ({ ...prev, visible: false }));
|
setSensitiveContentModal((prev) => ({ ...prev, visible: false }));
|
||||||
@@ -115,7 +137,7 @@ const Lyrics = ({ navigation }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const remainingTextual = remaining.filter((item) =>
|
const remainingTextual = remaining.filter((item) =>
|
||||||
segmentRequiresLyrics(item.type)
|
segmentRequiresLyrics(item.type),
|
||||||
);
|
);
|
||||||
sections.push(...remainingTextual);
|
sections.push(...remainingTextual);
|
||||||
|
|
||||||
@@ -194,7 +216,7 @@ const Lyrics = ({ navigation }) => {
|
|||||||
if (invalid) {
|
if (invalid) {
|
||||||
alertMessage(
|
alertMessage(
|
||||||
"Champs incomplets",
|
"Champs incomplets",
|
||||||
"Chaque section doit contenir du texte."
|
"Chaque section doit contenir du texte.",
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -217,7 +239,7 @@ const Lyrics = ({ navigation }) => {
|
|||||||
normalizedOldLyrics.every(
|
normalizedOldLyrics.every(
|
||||||
(s, i) =>
|
(s, i) =>
|
||||||
s.type === normalizedNewLyrics[i]?.type &&
|
s.type === normalizedNewLyrics[i]?.type &&
|
||||||
s.lyrics === normalizedNewLyrics[i]?.lyrics
|
s.lyrics === normalizedNewLyrics[i]?.lyrics,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 1) Appel de la Cloud Function de modération avant tout enregistrement
|
// 1) Appel de la Cloud Function de modération avant tout enregistrement
|
||||||
@@ -241,7 +263,7 @@ const Lyrics = ({ navigation }) => {
|
|||||||
: null;
|
: null;
|
||||||
alertMessage(
|
alertMessage(
|
||||||
"Contenu interdit",
|
"Contenu interdit",
|
||||||
[data?.message, quotes].filter(Boolean).join("\n\n")
|
[data?.message, quotes].filter(Boolean).join("\n\n"),
|
||||||
);
|
);
|
||||||
return; // stop here
|
return; // stop here
|
||||||
}
|
}
|
||||||
@@ -249,7 +271,7 @@ const Lyrics = ({ navigation }) => {
|
|||||||
if (data?.errorCode === "ANALYSE_FAILED") {
|
if (data?.errorCode === "ANALYSE_FAILED") {
|
||||||
alertMessage(
|
alertMessage(
|
||||||
"Analyse indisponible",
|
"Analyse indisponible",
|
||||||
"Impossible de vérifier la toxicité pour le moment. Réessayez plus tard."
|
"Impossible de vérifier la toxicité pour le moment. Réessayez plus tard.",
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -267,18 +289,18 @@ const Lyrics = ({ navigation }) => {
|
|||||||
|
|
||||||
const proceed = await confirmSensitiveContent(
|
const proceed = await confirmSensitiveContent(
|
||||||
"Contenu potentiellement sensible",
|
"Contenu potentiellement sensible",
|
||||||
msg
|
msg,
|
||||||
);
|
);
|
||||||
if (!proceed) return;
|
if (!proceed) return;
|
||||||
}
|
}
|
||||||
} catch (moderationError) {
|
} catch (moderationError) {
|
||||||
console.log(
|
console.log(
|
||||||
"Moderation call failed",
|
"Moderation call failed",
|
||||||
moderationError?.message || moderationError
|
moderationError?.message || moderationError,
|
||||||
);
|
);
|
||||||
alertMessage(
|
alertMessage(
|
||||||
"Analyse indisponible",
|
"Analyse indisponible",
|
||||||
"Impossible de vérifier la toxicité pour le moment. Réessayez plus tard."
|
"Impossible de vérifier la toxicité pour le moment. Réessayez plus tard.",
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -339,7 +361,7 @@ const Lyrics = ({ navigation }) => {
|
|||||||
text: "Continuer vers le Studio",
|
text: "Continuer vers le Studio",
|
||||||
onPress: handleNavigateToStudio,
|
onPress: handleNavigateToStudio,
|
||||||
},
|
},
|
||||||
]
|
],
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -404,11 +426,10 @@ const Lyrics = ({ navigation }) => {
|
|||||||
<Text style={styles.instructions}>
|
<Text style={styles.instructions}>
|
||||||
{strings.writing.lyrics.instructions}
|
{strings.writing.lyrics.instructions}
|
||||||
</Text>
|
</Text>
|
||||||
<View style={styles.personalizationBanner}>
|
<Text style={styles.personalizationText}>
|
||||||
<Text style={styles.personalizationText}>
|
N’hesites pas a personnaliser les paroles proposées en ajoutant
|
||||||
{strings.writing.lyrics.personalizationBanner}
|
ta touche personnelle mettre mieux en évidence
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
<CustomInput
|
<CustomInput
|
||||||
label="Titre"
|
label="Titre"
|
||||||
@@ -419,11 +440,7 @@ const Lyrics = ({ navigation }) => {
|
|||||||
multiline={false}
|
multiline={false}
|
||||||
maxLength={60}
|
maxLength={60}
|
||||||
onLayout={(e) => {
|
onLayout={(e) => {
|
||||||
e.persist();
|
updateLayoutAtIndex(0, e?.nativeEvent?.layout);
|
||||||
setItemsContainerLayout((prev) => [
|
|
||||||
...prev,
|
|
||||||
e?.nativeEvent?.layout,
|
|
||||||
]);
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{sections.map((s, idx) => {
|
{sections.map((s, idx) => {
|
||||||
@@ -460,18 +477,9 @@ const Lyrics = ({ navigation }) => {
|
|||||||
height={inputHeight}
|
height={inputHeight}
|
||||||
value={s?.lyrics || ""}
|
value={s?.lyrics || ""}
|
||||||
setValue={(val) => setSectionAt(idx, val)}
|
setValue={(val) => setSectionAt(idx, val)}
|
||||||
onFocus={() => {
|
onFocus={() => handleSectionFocus(idx)}
|
||||||
setIsFocus(idx);
|
|
||||||
scrollRef?.current?.scrollTo({
|
|
||||||
y: itemsContainerLayout[idx + 1]?.y,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
onLayout={(e) => {
|
onLayout={(e) => {
|
||||||
e.persist();
|
updateLayoutAtIndex(idx + 1, e?.nativeEvent?.layout);
|
||||||
setItemsContainerLayout((prev) => [
|
|
||||||
...prev,
|
|
||||||
e?.nativeEvent?.layout,
|
|
||||||
]);
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -558,18 +566,12 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
lineHeight: 20,
|
lineHeight: 20,
|
||||||
},
|
},
|
||||||
personalizationBanner: {
|
|
||||||
backgroundColor: Palette.ultraLightWhite,
|
|
||||||
borderRadius: 12,
|
|
||||||
paddingVertical: 10,
|
|
||||||
paddingHorizontal: 12,
|
|
||||||
marginTop: 4,
|
|
||||||
},
|
|
||||||
personalizationText: {
|
personalizationText: {
|
||||||
color: Palette.white,
|
color: Palette.white,
|
||||||
fontFamily: FONT_FAMILY.InterMedium,
|
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||||
fontSize: 14,
|
fontSize: 18,
|
||||||
lineHeight: 20,
|
lineHeight: 24,
|
||||||
|
marginTop: 8,
|
||||||
},
|
},
|
||||||
instrumentalBlock: {
|
instrumentalBlock: {
|
||||||
padding: 16,
|
padding: 16,
|
||||||
|
|||||||
@@ -115,49 +115,6 @@ export const ChooseCoverType = () => {
|
|||||||
[currentUID, projectId],
|
[currentUID, projectId],
|
||||||
);
|
);
|
||||||
|
|
||||||
// const pickUserImage = useCallback(async () => {
|
|
||||||
// try {
|
|
||||||
// await setIsLoading(true);
|
|
||||||
// const result = await ImagePicker.launchImageLibraryAsync({
|
|
||||||
// mediaTypes: ["images"],
|
|
||||||
// allowsEditing: true,
|
|
||||||
// aspect: [1, 1],
|
|
||||||
// quality: 1,
|
|
||||||
// });
|
|
||||||
|
|
||||||
// const uri = result?.assets?.[0]?.uri || null;
|
|
||||||
// if (!uri) return;
|
|
||||||
|
|
||||||
// if (!projectId) return;
|
|
||||||
// const path = `musics/${projectId}/userSelectedCover.png`;
|
|
||||||
|
|
||||||
// const { resultURI } = await uploadFileToFirebase({
|
|
||||||
// uri,
|
|
||||||
// path,
|
|
||||||
// shouldCompress: true,
|
|
||||||
// fileType: "IMAGE",
|
|
||||||
// });
|
|
||||||
|
|
||||||
// if (!resultURI) {
|
|
||||||
// throw new Error("Erreur lors de l'upload");
|
|
||||||
// }
|
|
||||||
// await updateProjectData({
|
|
||||||
// cover: {
|
|
||||||
// result: resultURI,
|
|
||||||
// },
|
|
||||||
// });
|
|
||||||
// navigate(Routes.ValidateCover);
|
|
||||||
// } catch (e) {
|
|
||||||
// console.log("onPickUserImage error", e?.message);
|
|
||||||
// } finally {
|
|
||||||
// await setIsLoading(false);
|
|
||||||
// }
|
|
||||||
// }, [projectId, setIsLoading, updateProjectData]);
|
|
||||||
|
|
||||||
// const handlePickUserImage = useCallback(() => {
|
|
||||||
// ensureArtistPreference(pickUserImage);
|
|
||||||
// }, [ensureArtistPreference, pickUserImage]);
|
|
||||||
|
|
||||||
const handleGenerateCover = useCallback(() => {
|
const handleGenerateCover = useCallback(() => {
|
||||||
if (hasFinalCover || hasGeneratedOptions) {
|
if (hasFinalCover || hasGeneratedOptions) {
|
||||||
navigate(Routes.ValidateCover);
|
navigate(Routes.ValidateCover);
|
||||||
@@ -300,10 +257,10 @@ export const ChooseCoverType = () => {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<FullscreenIntroVideo
|
{/*<FullscreenIntroVideo*/}
|
||||||
visible={showIntro}
|
{/* visible={showIntro}*/}
|
||||||
onClose={() => setShowIntro(false)}
|
{/* onClose={() => setShowIntro(false)}*/}
|
||||||
/>
|
{/*/>*/}
|
||||||
<Modal
|
<Modal
|
||||||
visible={choiceVisible}
|
visible={choiceVisible}
|
||||||
transparent
|
transparent
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
const globalScope =
|
||||||
|
typeof globalThis !== "undefined"
|
||||||
|
? globalThis
|
||||||
|
: typeof global !== "undefined"
|
||||||
|
? global
|
||||||
|
: typeof window !== "undefined"
|
||||||
|
? window
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const hasObjectUrlSupport = Boolean(
|
||||||
|
globalScope?.URL && typeof globalScope.URL.createObjectURL === "function"
|
||||||
|
);
|
||||||
|
|
||||||
|
const REGISTRY_KEY = "__musiclandBlobRegistry";
|
||||||
|
|
||||||
|
const blobRegistry = (() => {
|
||||||
|
if (globalScope && globalScope[REGISTRY_KEY] instanceof Map) {
|
||||||
|
return globalScope[REGISTRY_KEY];
|
||||||
|
}
|
||||||
|
const registry = new Map();
|
||||||
|
if (globalScope) {
|
||||||
|
try {
|
||||||
|
globalScope[REGISTRY_KEY] = registry;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
return registry;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const isValidBlobUrl = (url) =>
|
||||||
|
hasObjectUrlSupport && typeof url === "string" && url.startsWith("blob:");
|
||||||
|
|
||||||
|
export const registerBlobUrl = (url, blob) => {
|
||||||
|
if (!isValidBlobUrl(url) || !blob) return;
|
||||||
|
blobRegistry.set(url, blob);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getBlobForUrl = (url) => {
|
||||||
|
if (!isValidBlobUrl(url)) return null;
|
||||||
|
return blobRegistry.get(url) || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const releaseBlobUrl = (url) => {
|
||||||
|
if (!isValidBlobUrl(url)) return;
|
||||||
|
blobRegistry.delete(url);
|
||||||
|
try {
|
||||||
|
globalScope.URL.revokeObjectURL(url);
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
@@ -7,10 +7,34 @@ export const CREATION_STAGE_KEYS = [
|
|||||||
"publisher",
|
"publisher",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const getSectionLyrics = (section) => {
|
||||||
|
if (typeof section === "string") {
|
||||||
|
return section.trim();
|
||||||
|
}
|
||||||
|
if (section && typeof section === "object") {
|
||||||
|
const value =
|
||||||
|
typeof section.lyrics === "string" ? section.lyrics.trim() : "";
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
const getLyricsCount = (project) => {
|
const getLyricsCount = (project) => {
|
||||||
if (!project) return 0;
|
if (!project) return 0;
|
||||||
const { lyrics } = project;
|
const { lyrics } = project;
|
||||||
return Array.isArray(lyrics) ? lyrics.length : 0;
|
if (Array.isArray(lyrics)) {
|
||||||
|
return lyrics.reduce(
|
||||||
|
(count, section) => (getSectionLyrics(section) ? count + 1 : count),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (lyrics && typeof lyrics === "object") {
|
||||||
|
return Object.values(lyrics).reduce(
|
||||||
|
(count, value) => (getSectionLyrics(value) ? count + 1 : count),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return getSectionLyrics(lyrics) ? 1 : 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStageMetadata = (project) => {
|
const getStageMetadata = (project) => {
|
||||||
@@ -73,6 +97,9 @@ const getStageLockState = (key, metadata) => {
|
|||||||
}
|
}
|
||||||
return metadata.lyricsCount <= 0;
|
return metadata.lyricsCount <= 0;
|
||||||
case "director":
|
case "director":
|
||||||
|
if (metadata.hasPlaybackAsset) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
return !metadata.hasCover;
|
return !metadata.hasCover;
|
||||||
case "publisher":
|
case "publisher":
|
||||||
return !metadata.hasPlaybackAsset;
|
return !metadata.hasPlaybackAsset;
|
||||||
|
|||||||
Reference in New Issue
Block a user