more fixes web + mobile

This commit is contained in:
Thomas Demirdjian
2025-11-20 11:26:24 +01:00
parent fe9b2b9e8d
commit 70c94c97b4
32 changed files with 1214 additions and 226 deletions
+3 -2
View File
@@ -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>
+1 -1
View File
@@ -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 {
+76
View File
@@ -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 &gt;=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
View File
@@ -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

+242
View File
@@ -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;
+16 -1
View File
@@ -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]);
+28 -2
View File
@@ -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}>
+8 -1
View File
@@ -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}
+4 -1
View File
@@ -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={[
+2
View File
@@ -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 -3
View File
@@ -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"
+32 -36
View File
@@ -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 }) => {
+6 -6
View File
@@ -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%",
+2 -1
View File
@@ -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",
+4 -4
View File
@@ -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",
+25 -3
View File
@@ -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) => {
+1 -1
View File
@@ -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}
+82
View File
@@ -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>
+121 -13
View File
@@ -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>
)} )}
-1
View File
@@ -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",
+11 -1
View File
@@ -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
+197 -8
View File
@@ -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={{
+41
View File
@@ -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
+12 -2
View File
@@ -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;
} }
+13 -2
View File
@@ -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;
} }
+1 -1
View File
@@ -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) => {
+43 -41
View File
@@ -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}> Nhesites 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,
+4 -47
View File
@@ -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
+48
View File
@@ -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 {}
};
+28 -1
View File
@@ -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;