last fixes, home and videos

This commit is contained in:
Thomas Demirdjian
2025-11-10 14:24:16 +01:00
parent d7d9211fbe
commit dd4cf9b4d4
40 changed files with 984 additions and 647 deletions
+1 -1
View File
@@ -2,7 +2,7 @@ exports.GEMINI_API_KEY = "AIzaSyBoPQC5ZaMKP73TlKGpZQp1mAw8ArHiH9Y";
exports.SUNO_API_KEY = "c1636e04f606811511e19ec6e1545aa6"; // api key
exports.RESEND_API_KEY = "re_";
exports.RESEND_API_KEY = "re_NLcHmYiz_4LoFrzvPBbShNQBgNGTQmBbu";
exports.STRIPE_SECRET_KEY =
"sk_test_51SPfcjCzf2o5bDRdUFGNrQYIE271EDfS2Ucn31f98Ublttcl1EBNRoOoJX1RfXXzHp7mKRGrIlCG24biiqUZ2YMh00s9WODluu";
+81 -20
View File
@@ -7,7 +7,6 @@ const {
} = require("../config/suno");
const { SUNO_API_KEY } = require("../config/keys");
const axios = require("axios");
const admin = require("firebase-admin");
const { FieldValue } = require("firebase-admin/firestore");
const { refList } = require("../index");
@@ -41,7 +40,7 @@ const STRUCTURE_ALIASES = {
"intro instrumentale longue": "long_intro",
"pré-refrain": "pre_refrain_instrumental",
"pre-refrain": "pre_refrain_instrumental",
"pre_refrain": "pre_refrain_instrumental",
pre_refrain: "pre_refrain_instrumental",
"pre chorus": "pre_refrain_instrumental",
"pre-chorus": "pre_refrain_instrumental",
prechorus: "pre_refrain_instrumental",
@@ -62,7 +61,9 @@ const STRUCTURE_ALIASES = {
};
const normalizeStructureValue = (value) => {
const raw = String(value || "").trim().toLowerCase();
const raw = String(value || "")
.trim()
.toLowerCase();
if (!raw) return "";
if (STRUCTURE_PROMPT_LABELS[raw]) return raw;
if (STRUCTURE_ALIASES[raw]) return STRUCTURE_ALIASES[raw];
@@ -160,9 +161,7 @@ const moderationIndicatesBlock = (moderation = {}, fallbackText = "") => {
const excerpts = Array.isArray(moderation.excerpts)
? moderation.excerpts
: [];
const reasons = Array.isArray(moderation.reasons)
? moderation.reasons
: [];
const reasons = Array.isArray(moderation.reasons) ? moderation.reasons : [];
const keywordDetected = (value) =>
typeof value === "string" && TOXIC_KEYWORDS.some((rx) => rx.test(value));
@@ -182,12 +181,7 @@ exports.generateLyrics = onCall({}, async ({ auth = {}, data = {} }) => {
style = "",
audience = "",
emotion = "",
structure: rawStructure = [
"couplet",
"refrain",
"couplet",
"refrain",
],
structure: rawStructure = ["couplet", "refrain", "couplet", "refrain"],
rhymes = "",
} = data;
@@ -248,14 +242,14 @@ exports.generateLyrics = onCall({}, async ({ auth = {}, data = {} }) => {
const system = `
Tu es un parolier expert en chanson française moderne et populaire.
Tu écris des chansons marquantes, accessibles, sur des thématiques d'innovation, d'esprit d'équipe et de créativité digitale.
Ton écriture doit toucher à la fois l'humain, le collectif et la passion d'entreprendre.
Analyse attentivement les informations placées entre balises XML-like.
Génère un titre accrocheur et mémorable en plus des paroles, tout en respectant strictement la structure demandée.
Retourne uniquement un JSON conforme au schéma fourni, sans ajouter d'autres textes.
`.trim();
const structureTags = (Array.isArray(promptStructure) ? promptStructure : [])
const structureTags = (
Array.isArray(promptStructure) ? promptStructure : []
)
.map((part, index) => {
const content = part || "";
return ` <SECTION ordre="${index + 1}">${content}</SECTION>`;
@@ -314,14 +308,12 @@ ${structureTags || fallbackStructureTags}
lyrics: z
.string()
.describe(
"Paroles de la section, chaque ligne séparée " +
"par un retour à la ligne",
"Paroles de la section, chaque ligne séparée par un retour à la ligne",
),
}),
)
.describe(
"Paroles de la chanson sous forme de tableau de sections " +
"structurées.",
"Paroles de la chanson sous forme de tableau de sections structurées.",
),
lyricsDescription: z
.string()
@@ -339,6 +331,74 @@ ${structureTags || fallbackStructureTags}
}
});
const severityScore = (severity = "") => {
const normalized = String(severity || "").toLowerCase();
switch (normalized) {
case "critical":
return 4;
case "high":
return 3;
case "medium":
return 2;
case "low":
return 1;
default:
return 0;
}
};
const softenModerationDecision = (rawResult = {}) => {
const result = { ...rawResult };
const excerpts = Array.isArray(result.excerpts) ? result.excerpts : [];
const reasons = Array.isArray(result.reasons) ? result.reasons : [];
const highestSeverity = excerpts.reduce(
(max, excerpt) => Math.max(max, severityScore(excerpt?.severity)),
0,
);
const score =
typeof result.score === "number" && !Number.isNaN(result.score)
? Math.min(Math.max(result.score, 0), 1)
: 0;
const narrativeHint = reasons.some((reason) =>
/narrati|story|persona|fiction|metaphor|metaphore|récit|roleplay|contexte/i.test(
reason || "",
),
);
const adjustments = [];
if (result.blocked) {
const shouldUnblockLowSeverity = highestSeverity <= 1 && score < 0.65;
if (shouldUnblockLowSeverity) {
result.blocked = false;
adjustments.push("auto-unblock-low-severity");
}
}
if (!result.blocked && result.flagged) {
const lowSignal = highestSeverity <= 1 && score < 0.4;
const contextual = narrativeHint && highestSeverity <= 2 && score < 0.55;
if (lowSignal) {
result.flagged = false;
adjustments.push("drop-flag-low-signal");
} else if (contextual) {
result.flagged = false;
adjustments.push("drop-flag-contextual");
}
}
return {
...result,
moderationAdjustments: adjustments,
moderationCalibration: {
highestSeverity,
score,
},
};
};
// Vérifie la toxicité des paroles via Gemini et retourne un résultat structuré
// Nom Cloud Function: lyrics-analyseLyricsToxicity
exports.analyseLyricsToxicity = onCall({}, async ({ data = {} }) => {
@@ -364,10 +424,11 @@ exports.analyseLyricsToxicity = onCall({}, async ({ data = {} }) => {
});
const parsed = requestSchema.parse(data || {});
const result = await analyseLyrics({
const aiResult = await analyseLyrics({
title: parsed.title || "",
lyrics: parsed.lyrics,
});
const result = softenModerationDecision(aiResult);
// Prépare un message lisible pour le front
const highCats = Object.entries(result.categories || {})
Binary file not shown.

Before

Width:  |  Height:  |  Size: 653 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 590 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 712 KiB

After

Width:  |  Height:  |  Size: 702 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 659 KiB

After

Width:  |  Height:  |  Size: 620 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 702 KiB

After

Width:  |  Height:  |  Size: 681 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 776 KiB

After

Width:  |  Height:  |  Size: 703 KiB

-2
View File
@@ -219,8 +219,6 @@ export const ai = {
malik,
bena,
john,
leftIcon: require("./UI/leftIcon.png"),
rightIcon: require("./UI/rightIcon.png"),
};
export const videos = {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 795 KiB

+121
View File
@@ -0,0 +1,121 @@
import React from "react";
import { StyleSheet, Text, View } from "react-native";
import CoinIcon from "./CoinIcon";
import { FONT_FAMILY } from "../styles/Fonts";
const defaultFormatOptions = {
minimumFractionDigits: 0,
maximumFractionDigits: 0,
};
const formatAmount = (value, options = defaultFormatOptions) => {
if (typeof value !== "number" || !Number.isFinite(value)) {
return null;
}
try {
return new Intl.NumberFormat("fr-FR", {
...defaultFormatOptions,
...options,
}).format(value);
} catch (_error) {
return `${value}`;
}
};
const extractNumericValue = (value) => {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string") {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return null;
};
const CreditAmount = ({
value,
style,
textStyle,
iconSize = 18,
iconStyle,
iconPosition = "right",
gap = 6,
showPlus = false,
formatterOptions,
accessibilityLabel,
}) => {
const numericValue = React.useMemo(
() => extractNumericValue(value),
[value],
);
const resolvedValue =
numericValue !== null
? numericValue
: value ?? 0;
const formattedValue =
numericValue !== null
? formatAmount(resolvedValue, formatterOptions) ?? `${resolvedValue}`
: typeof resolvedValue === "string"
? resolvedValue
: `${resolvedValue}`;
const prefix =
numericValue !== null && showPlus && numericValue > 0 ? "+" : "";
const a11yLabel =
accessibilityLabel || `${prefix}${formattedValue} pièces`;
const containerStyles = Array.isArray(style)
? [styles.container, { gap }, ...style]
: [styles.container, { gap }, style];
const textStyles = Array.isArray(textStyle)
? [styles.value, ...textStyle]
: [styles.value, textStyle];
const iconStyles = Array.isArray(iconStyle)
? [styles.icon, ...iconStyle]
: [styles.icon, iconStyle];
return (
<View
style={containerStyles}
accessibilityRole="text"
accessibilityLabel={a11yLabel}
>
{iconPosition === "left" ? (
<CoinIcon size={iconSize} style={iconStyles} />
) : null}
<Text style={textStyles}>{`${prefix}${formattedValue}`}</Text>
{iconPosition === "right" ? (
<CoinIcon size={iconSize} style={iconStyles} />
) : null}
</View>
);
};
const styles = StyleSheet.create({
container: {
flexDirection: "row",
alignItems: "center",
},
value: {
fontFamily: FONT_FAMILY.InterSemiBold,
fontSize: 16,
color: "#fff",
},
icon: {
width: 18,
height: 18,
},
});
export default CreditAmount;
+4 -1
View File
@@ -11,6 +11,7 @@ const MusicLandHeader = ({
showSkip = false,
progress = 1,
logo = null,
style,
}) => {
const isWeb = Platform.OS === "web";
const backButtonStyle = isWeb
@@ -34,7 +35,7 @@ const MusicLandHeader = ({
};
return (
<View style={{ alignItems: "center", gap: 16 }}>
<View style={{ alignItems: "center", gap: 16, ...style }}>
<View style={{ width: "100%", ...Style.containerRow, gap: 16 }}>
<Pressable style={backButtonStyle} onPress={onPressBack}>
<Image
@@ -72,6 +73,7 @@ const MusicLandHeader = ({
</Pressable>
)}
</View>
{logo && (
<Image
source={logo}
style={{
@@ -80,6 +82,7 @@ const MusicLandHeader = ({
resizeMode: "contain",
}}
/>
)}
</View>
);
};
+37 -6
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { StyleSheet, Text, View } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, {
@@ -24,6 +24,7 @@ export default ({
const offset = useSharedValue(0);
const boxWidth = useSharedValue(INITIAL_BOX_SIZE);
const [layout, setLayout] = useState(null);
const seekingRef = useRef(false);
const SLIDER_WIDTH = layout?.width;
const MAX_VALUE = SLIDER_WIDTH - INITIAL_BOX_SIZE;
@@ -31,12 +32,34 @@ export default ({
const pan = Gesture.Pan()
.enabled(seekEnabled)
.onBegin(() => {
if (seekEnabled && typeof onSeekStart === "function") {
// Notify JS thread that user started seeking (e.g., pause audio)
if (
seekEnabled &&
typeof onSeekStart === "function" &&
!seekingRef.current
) {
seekingRef.current = true;
runOnJS(onSeekStart)();
}
})
.onStart(() => {
if (
seekEnabled &&
typeof onSeekStart === "function" &&
!seekingRef.current
) {
seekingRef.current = true;
runOnJS(onSeekStart)();
}
})
.onChange((event) => {
if (
seekEnabled &&
typeof onSeekStart === "function" &&
!seekingRef.current
) {
seekingRef.current = true;
runOnJS(onSeekStart)();
}
offset.value =
Math.abs(offset.value) <= MAX_VALUE
? offset.value + event.changeX <= 0
@@ -50,14 +73,22 @@ export default ({
boxWidth.value = newWidth;
})
.onEnd(() => {
if (!seekEnabled || !onSeek || !MAX_VALUE) return;
if (seekEnabled && typeof onSeek === "function" && MAX_VALUE) {
const ratio =
MAX_VALUE > 0 ? Math.min(1, Math.max(0, offset.value / MAX_VALUE)) : 0;
MAX_VALUE > 0
? Math.min(1, Math.max(0, offset.value / MAX_VALUE))
: 0;
// Reanimated -> JS thread bridge
runOnJS(onSeek)(ratio);
}
if (seekingRef.current && typeof onSeekEnd === "function") {
seekingRef.current = false;
runOnJS(onSeekEnd)();
}
})
.onFinalize(() => {
if (seekEnabled && typeof onSeekEnd === "function") {
if (seekingRef.current && typeof onSeekEnd === "function") {
seekingRef.current = false;
runOnJS(onSeekEnd)();
}
});
+7 -11
View File
@@ -1,7 +1,6 @@
import React from "react";
import {
ActivityIndicator,
Image,
Linking,
Modal,
Pressable,
@@ -11,7 +10,6 @@ import {
View,
} from "react-native";
import { BlurView } from "expo-blur";
import { icons } from "../../assets";
import BorderGradientButton from "../BorderGradientButton";
import GradientButton from "../GradientButton";
import CreateLyricsHeader from "../../screens/Writing/components/CreateLyricsHeader";
@@ -19,6 +17,7 @@ import { Palette, gutters } from "../../styles";
import { FONT_FAMILY } from "../../styles/Fonts";
import { getFunctionsClient } from "../../config/firebase";
import { isWeb } from "../../hooks/useLayoutType";
import CreditAmount from "../CreditAmount";
const WEB_MODAL_MAX_WIDTH = 1000;
const FUNCTIONS_REGION = "europe-west1";
@@ -73,10 +72,12 @@ function CoinPackCard({ pack, selected, onSelect }) {
>
<BlurView intensity={20} tint="dark" style={styles.cardBlur}>
<View style={styles.cardHeader}>
<View style={styles.coinRow}>
<Image source={icons.coin} style={styles.coinIcon} />
<Text style={styles.coinAmount}>{pack?.coinAmount} pièces</Text>
</View>
<CreditAmount
value={pack?.coinAmount}
style={styles.coinRow}
textStyle={styles.coinAmount}
iconSize={26}
/>
{pack?.name ? <Text style={styles.packName}>{pack.name}</Text> : null}
</View>
{pack?.description ? (
@@ -395,11 +396,6 @@ const styles = StyleSheet.create({
alignItems: "center",
gap: 10,
},
coinIcon: {
width: 26,
height: 26,
resizeMode: "contain",
},
coinAmount: {
fontFamily: FONT_FAMILY.InterBold,
fontSize: 20,
+7 -20
View File
@@ -4,9 +4,9 @@ import { KeyboardAwareScrollView } from "react-native-keyboard-aware-scroll-view
import { SafeAreaView } from "react-native-safe-area-context";
import React from "reactn";
import { responsiveHeight } from "../actions/responsiveSizes.js";
import { icons, subBadges } from "../assets";
import { subBadges } from "../assets";
import BaseHeader from "../components/BaseHeader";
import CoinIcon from "../components/CoinIcon";
import CreditAmount from "../components/CreditAmount";
import ConnectBtn from "../components/ConnectBtn.js";
import CoinPackModal from "../components/modal/CoinPackModal";
import NavigateHeader from "../components/NavigateHeader";
@@ -65,16 +65,6 @@ export default ({
return 0;
}, [currentUserData?.coins]);
const formattedCoins = React.useMemo(() => {
try {
return new Intl.NumberFormat("fr-FR", {
maximumFractionDigits: 0,
}).format(coinBalance);
} catch (_error) {
return `${coinBalance}`;
}
}, [coinBalance]);
const showCoinBadge = isWeb && !!currentUID && showCoin;
const [isCoinModalVisible, setCoinModalVisible] = React.useState(false);
@@ -184,7 +174,6 @@ export default ({
flex: 1,
minHeight: isWeb ? "100svh" : "100%",
position: "relative",
// paddingHorizontal: isWeb ? gutters : 0,
backgroundColor: backgroundColor,
}}
>
@@ -192,7 +181,6 @@ export default ({
<Image
source={backgroundImg}
resizeMode="cover"
// blurRadius={!isWeb ? blurIntensity : undefined}
blurIntensity={blurIntensity}
style={{
position: "absolute",
@@ -233,16 +221,15 @@ export default ({
gap: 8,
}}
>
<CoinIcon size={22} />
<Text
style={{
<CreditAmount
value={coinBalance}
iconSize={22}
textStyle={{
color: "#ffffff",
fontFamily: FONT_FAMILY.InterSemiBold,
fontSize: 14,
}}
>
{formattedCoins}
</Text>
/>
</Pressable>
{subscriptionBadgeSource ? (
<Pressable
+1 -1
View File
@@ -62,7 +62,7 @@ export const BottomTabScreen = () => {
name={Routes.Playbacks}
component={Playbacks}
options={{
tabBarLabel: "Playbacks",
tabBarLabel: "Playback",
headerShown: false,
tabBarShowLabel: true,
tabBarIcon: ({ focused }) => renderIcon(tabs.mic, focused),
+1 -1
View File
@@ -64,7 +64,7 @@ export const Routes = {
Create: "Create",
HitParade: "HitParade",
Playbacks: "Playbacks",
Playbacks: "Playback",
Library: "Library",
AllMyPlaylist: "AllMyPlaylist",
+20 -13
View File
@@ -12,10 +12,12 @@ import firebase, {
playlistsRef,
projectsRef,
usersRef,
videosRef,
} from "../config/firebase";
import { getUserPreferredArtistName } from "../utils/artistName";
import { getLikeFieldPath, LIKE_TARGET } from "../utils/likes";
import { ensureAuthenticated } from "../utils/authRedirect";
import { isWeb } from "../hooks/useLayoutType";
const SONG_LIKES_FIELD = getLikeFieldPath(LIKE_TARGET.SONG);
const PLAYBACK_LIKES_FIELD = getLikeFieldPath(LIKE_TARGET.PLAYBACK);
@@ -43,10 +45,8 @@ export default ({ children }) => {
});
// Subscribe to user's projects (musics)
const {
data: userProjects = [],
loading: userProjectsLoading = true,
} = useDataFromRef({
const { data: userProjects = [], loading: userProjectsLoading = true } =
useDataFromRef({
ref: currentUID
? projectsRef
.where("userId", "==", currentUID)
@@ -136,17 +136,17 @@ export default ({ children }) => {
{
selectedProjectId: projectId || null,
},
{ merge: true }
{ merge: true },
);
console.log("update selected project");
} catch (error) {
console.log(
"UserDataProvider: unable to persist selectedProjectId",
error?.message || error
error?.message || error,
);
}
},
[currentUID]
[currentUID],
);
const selectProject = useCallback(
@@ -161,12 +161,12 @@ export default ({ children }) => {
persistSelectedProjectId(safeId).catch((error) => {
console.log(
"UserDataProvider: persist selectedProjectId failed",
error?.message || error
error?.message || error,
);
});
}
},
[currentUID, persistSelectedProjectId, setSelectedProject]
[currentUID, persistSelectedProjectId, setSelectedProject],
);
const resetSelectedProject = useCallback(() => {
@@ -183,7 +183,7 @@ export default ({ children }) => {
...partial,
updatedAt: firebase.firestore.FieldValue.serverTimestamp(),
},
options
options,
);
} catch (e) {
console.log("updateProjectData error", e?.message);
@@ -209,7 +209,7 @@ export default ({ children }) => {
try {
await setIsLoading(true);
const artistDisplayName = getUserPreferredArtistName(
currentUserDoc || currentUserData || {}
currentUserDoc || currentUserData || {},
);
const payload = {
userId: currentUID || null,
@@ -238,7 +238,7 @@ export default ({ children }) => {
followedBy: arrayUnion(currentUID),
lastFollowersUpdateAt: new Date(),
},
{ merge: true }
{ merge: true },
);
setTooltip({ type: "success", text: "Abonnement mis à jour" });
}
@@ -259,7 +259,7 @@ export default ({ children }) => {
followedBy: arrayRemove(currentUID),
lastFollowersUpdateAt: new Date(),
},
{ merge: true }
{ merge: true },
);
setTooltip({
type: "success",
@@ -416,6 +416,11 @@ export default ({ children }) => {
userProjects,
]);
const { data: videos } = useDataFromRef({
ref: videosRef.doc("fr"),
simpleRef: true,
});
return (
<UserDataContext.Provider
value={{
@@ -452,6 +457,8 @@ export default ({ children }) => {
setSelectedProjectId: selectProject,
updateProjectData,
createNewProject,
videos,
}}
>
{children}
+2 -2
View File
@@ -274,7 +274,7 @@ const HitParade = () => {
gap: 12,
}}
>
{["Chansons", "Playbacks"].map((item, index) => (
{["Chansons", "Playback"].map((item, index) => (
<BorderGradientButton
key={index}
title={item}
@@ -291,7 +291,7 @@ const HitParade = () => {
{selected === "Chansons" &&
(isWeb ? renderSongsWeb() : renderSongsMobile())}
{selected === "Playbacks" &&
{selected === "Playback" &&
(isWeb ? renderPlaybacksWeb() : renderPlaybacksMobile())}
{/* "Clips" pourra reprendre la même logique si tu le réactives */}
</View>
+10 -23
View File
@@ -8,15 +8,14 @@ import React, {
import { StyleSheet, Text, View } from "react-native";
import { Image as ExpoImage } from "expo-image";
import useMinuit from "react-native-minuit/src/hooks/useMinuit.js";
import { background, cardsImg, icons, videos } from "../../assets";
import { background, cardsImg, icons } from "../../assets";
import Alert from "../../components/Alert";
import FullscreenIntroVideo from "../../components/FullscreenIntroVideo";
import GradientButton from "../../components/GradientButton";
import MoreMenu from "../../components/MoreMenu";
import ProjectDropDown from "../../components/ProjectDropDown/ProjectDropDown";
import ShareBtn from "../../components/ShareBtn/ShareBtn";
import { projectsRef, usersRef, videosRef } from "../../config/firebase";
import useDataFromRef from "../../hooks/useDataFromRef";
import { projectsRef, usersRef } from "../../config/firebase";
import { isWeb } from "../../hooks/useLayoutType.js";
import Page from "../../layouts/Page";
import LandingPage from "../LandingPage";
@@ -105,6 +104,7 @@ const Home = ({ navigation, route }) => {
currentUserData,
currentUID,
createNewProject,
videos,
} = useUser();
const { setTooltip } = useMinuit();
@@ -124,9 +124,7 @@ const Home = ({ navigation, route }) => {
const statusCandidates = [
pickStatus(currentUserData?.stripeSubscriptionStatus),
pickStatus(currentUserData?.stripeSubscription?.status),
pickStatus(
currentUserData?.stripeSubscription?.stripeSubscriptionStatus,
),
pickStatus(currentUserData?.stripeSubscription?.stripeSubscriptionStatus),
pickStatus(currentUserData?.stripeSubscription?.metadata?.status),
].filter(Boolean);
@@ -410,11 +408,7 @@ const Home = ({ navigation, route }) => {
const adventureStarted =
hasLocalAdventureFlag || !!currentUserData?.adventureStarted;
const { data: video } = useDataFromRef({
ref: videosRef.doc("fr"),
simpleRef: true,
});
const videoUrl = isWeb ? video?.landingWeb : video?.landing;
const videoUrl = isWeb ? videos?.landingWeb : videos?.landing;
const homeBackgroundImage = background.bgTrans;
const landingBackgroundImage = homeBackgroundImage;
@@ -447,12 +441,7 @@ const Home = ({ navigation, route }) => {
}
navigate(action.route, action.params);
},
[
currentProject,
ensureProjectSelected,
hasActiveProject,
handleStartNew,
],
[currentProject, ensureProjectSelected, hasActiveProject, handleStartNew],
);
const handleClubPress = useCallback(() => {
@@ -467,8 +456,8 @@ const Home = ({ navigation, route }) => {
scrollEnabled={false}
containerStyle={styles.page}
contentContainerStyle={styles.pageContent}
maxWidth={1600}
width="100%"
maxWidth={null}
backgroundColor="#303438"
>
<View style={styles.inner}>
@@ -534,6 +523,8 @@ const Home = ({ navigation, route }) => {
shareBtn
title="Landing Page"
backgroundImg={landingBackgroundImage}
contentContainerStyle={styles.pageContent}
backgroundColor="#303438"
>
<View
style={{
@@ -568,12 +559,9 @@ const styles = StyleSheet.create({
page: {
backgroundColor: "transparent",
padding: 0,
paddingLeft: isWeb ? 32 : 0,
paddingRight: isWeb ? 32 : 0,
paddingTop: 0,
paddingBottom: 0,
width: "100%",
alignSelf: "stretch",
},
pageContent: {
flexGrow: 1,
@@ -584,7 +572,6 @@ const styles = StyleSheet.create({
alignSelf: "stretch",
alignItems: "stretch",
justifyContent: "flex-start",
position: "relative",
},
centerImage: {
width: HOME_BACKGROUND_WIDTH + 120,
@@ -625,6 +612,7 @@ const styles = StyleSheet.create({
textTransform: "uppercase",
letterSpacing: 1,
marginBottom: 16,
marginTop: 15,
},
cardsGrid: {
width: "100%",
@@ -632,6 +620,5 @@ const styles = StyleSheet.create({
flexWrap: "wrap",
justifyContent: "space-between",
rowGap: 20,
columnGap: 20,
},
});
+95 -149
View File
@@ -1,5 +1,5 @@
import React, { memo } from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
import { Pressable, Text, View } from "react-native";
import { BlurView } from "expo-blur";
import { Image as ExpoImage } from "expo-image";
import FontAwesome from "@expo/vector-icons/FontAwesome";
@@ -14,7 +14,6 @@ const StageCard = ({
textAlign = "left",
isLocked,
onPress,
lockSide = "right",
}) => {
const isImageOnLeft = imagePosition !== "right";
const isTextRight = textAlign === "right";
@@ -22,16 +21,41 @@ const StageCard = ({
const imageBlock = (
<View
style={[
styles.imageContainer,
isImageOnLeft ? styles.imageLeft : styles.imageRight,
{
flex: 1,
alignItems: "center",
justifyContent: "center",
overflow: "hidden",
zIndex: 10,
},
isImageOnLeft
? {
borderTopLeftRadius: 20,
borderBottomLeftRadius: 20,
borderTopRightRadius: 0,
borderBottomRightRadius: 0,
}
: {
borderTopRightRadius: 20,
borderBottomRightRadius: 20,
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
},
]}
>
<ExpoImage
source={image}
contentFit="contain"
style={[
styles.image,
isImageOnLeft ? styles.imageAlignRight : styles.imageAlignLeft,
{
width: "100%",
height: "100%",
minHeight: 210,
borderRadius: 0,
},
isImageOnLeft
? { alignSelf: "flex-end" }
: { alignSelf: "flex-start" },
]}
/>
</View>
@@ -40,14 +64,26 @@ const StageCard = ({
const header = (
<View
style={[
styles.textHeader,
isTextRight ? styles.textHeaderAlignEnd : styles.textHeaderAlignStart,
{
width: "100%",
flexDirection: "row",
alignItems: "center",
marginBottom: 8,
},
isTextRight
? { justifyContent: "flex-end" }
: { justifyContent: "flex-start" },
]}
>
<Text
style={[
styles.step,
isTextRight ? styles.textRight : styles.textLeft,
{
fontFamily: FONT_FAMILY.InterSemiBold,
fontSize: 14,
color: Palette.white,
flexShrink: 1,
},
isTextRight ? { textAlign: "right" } : { textAlign: "left" },
]}
numberOfLines={1}
>
@@ -61,16 +97,42 @@ const StageCard = ({
tint="dark"
intensity={30}
style={[
styles.textContainer,
isImageOnLeft ? styles.textContainerRight : styles.textContainerLeft,
isTextRight ? styles.alignEnd : styles.alignStart,
{
flexGrow: 1,
flexShrink: 1,
maxWidth: 380,
paddingVertical: 20,
paddingHorizontal: 20,
justifyContent: "center",
alignSelf: "center",
},
isImageOnLeft
? {
borderTopRightRadius: 20,
borderBottomRightRadius: 20,
}
: {
borderTopLeftRadius: 20,
borderBottomLeftRadius: 20,
},
isTextRight ? { alignItems: "flex-end" } : { alignItems: "flex-start" },
]}
>
{isLocked ? (
<View
style={[
styles.lockBadge,
isTextRight ? styles.lockBadgeLeft : styles.lockBadgeRight,
{
position: "absolute",
top: 10,
backgroundColor: "rgba(0, 0, 0, 0.55)",
width: 32,
height: 32,
borderRadius: 16,
alignItems: "center",
justifyContent: "center",
zIndex: 2,
},
isTextRight ? { left: 10 } : { right: 10 },
]}
>
<FontAwesome name="lock" size={18} color={Palette.primary} />
@@ -79,8 +141,13 @@ const StageCard = ({
{header}
<Text
style={[
styles.description,
isTextRight ? styles.textRight : styles.textLeft,
isTextRight ? { textAlign: "right" } : { textAlign: "left" },
{
fontFamily: FONT_FAMILY.InterRegular,
fontSize: 13,
lineHeight: 20,
color: Palette.white,
},
]}
>
{description}
@@ -105,142 +172,21 @@ const StageCard = ({
onPress={onPress}
disabled={isLocked}
style={({ pressed }) => [
styles.card,
pressed && !isLocked && styles.cardPressed,
{
width: "48%",
},
pressed && !isLocked && { opacity: 0.85 },
]}
>
<View style={styles.inner}>{content}</View>
<View
style={{
flexDirection: "row",
}}
>
{content}
</View>
</Pressable>
);
};
export default memo(StageCard);
const styles = StyleSheet.create({
card: {
width: "48%",
minWidth: 260,
flexGrow: 0,
flexShrink: 0,
borderRadius: 20,
},
cardPressed: {
opacity: 0.85,
},
inner: {
flexDirection: "row",
alignItems: "stretch",
},
imageContainer: {
flexGrow: 1,
flexShrink: 1,
minHeight: 200,
alignItems: "center",
justifyContent: "center",
overflow: "hidden",
},
imageLeft: {
borderTopLeftRadius: 20,
borderBottomLeftRadius: 20,
borderTopRightRadius: 0,
borderBottomRightRadius: 0,
},
imageRight: {
borderTopRightRadius: 20,
borderBottomRightRadius: 20,
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
},
image: {
width: "85%",
height: "85%",
minHeight: 200,
borderRadius: 0,
},
imageAlignRight: {
alignSelf: "flex-end",
},
imageAlignLeft: {
alignSelf: "flex-start",
},
textContainer: {
flexGrow: 1,
flexShrink: 1,
minWidth: 240,
maxWidth: 320,
minHeight: 120,
maxHeight: 160,
paddingVertical: 10,
paddingHorizontal: 16,
borderRadius: 18,
justifyContent: "center",
alignSelf: "center",
},
textContainerRight: {
borderTopRightRadius: 18,
borderBottomRightRadius: 18,
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
marginLeft: -32,
},
textContainerLeft: {
borderTopLeftRadius: 18,
borderBottomLeftRadius: 18,
borderTopRightRadius: 0,
borderBottomRightRadius: 0,
marginRight: -32,
},
alignStart: {
alignItems: "flex-start",
},
alignEnd: {
alignItems: "flex-end",
},
textHeader: {
width: "100%",
flexDirection: "row",
alignItems: "center",
marginBottom: 8,
},
textHeaderAlignStart: {
justifyContent: "flex-start",
},
textHeaderAlignEnd: {
justifyContent: "flex-end",
},
step: {
fontFamily: FONT_FAMILY.InterSemiBold,
fontSize: 14,
color: Palette.white,
flexShrink: 1,
},
description: {
fontFamily: FONT_FAMILY.InterRegular,
fontSize: 13,
lineHeight: 20,
color: Palette.white,
},
textLeft: {
textAlign: "left",
},
textRight: {
textAlign: "right",
},
lockBadge: {
position: "absolute",
top: 10,
backgroundColor: "rgba(0, 0, 0, 0.55)",
width: 32,
height: 32,
borderRadius: 16,
alignItems: "center",
justifyContent: "center",
zIndex: 2,
},
lockBadgeLeft: {
left: 10,
},
lockBadgeRight: {
right: 10,
},
});
+3 -3
View File
@@ -97,9 +97,9 @@ export default function AllMyList() {
liked: {
title: "Aucun playback liké pour le moment",
description:
"Ajoutez vos playbacks favoris à vos likes pour les retrouver instantanément.",
"Ajoutez vos playback favoris à vos likes pour les retrouver instantanément.",
cta: {
label: "Voir les playbacks",
label: "Voir les playback",
action: () => navigate(Routes.Playbacks),
},
},
@@ -110,7 +110,7 @@ export default function AllMyList() {
}, [liked, scope]);
const loadingMessage = useMemo(() => {
const target = scope === "playback" ? "playbacks" : "musiques";
const target = scope === "playback" ? "playback" : "musiques";
return liked
? `Chargement des ${target} likés…`
: `Chargement des ${target}`;
+2 -2
View File
@@ -37,10 +37,10 @@ const BackTracks = () => {
return (
<CardContainer
label="Mes Playbacks"
label="Mes Playback"
onPress={() =>
navigate(Routes.AllMyList, {
title: "Mes playbacks",
title: "Mes playback",
scope: "playback",
liked: false,
})
@@ -31,10 +31,10 @@ const LikedPlayback = () => {
);
return (
<CardContainer
label="Playbacks likés"
label="Playback likés"
onPress={() =>
navigate(Routes.AllMyList, {
title: "Playbacks likés",
title: "Playback likés",
scope: "playback",
liked: true,
})
@@ -84,7 +84,7 @@ const LikedPlayback = () => {
<GradientButton
containerStyle={{ padding: 2 }}
onPress={() => navigate(Routes.Playbacks)}
title="Voir les playbacks"
title="Voir les playback"
></GradientButton>
</View>
)}
@@ -31,7 +31,7 @@ const ResearchHeader = ({
gap: 6,
}}
>
{["Musiques", "Playbacks", "Profils"].map((item, index) => (
{["Musiques", "Playback", "Profils"].map((item, index) => (
<Pressable key={index} onPress={() => onPress(item)}>
<BorderGradient
gradientProps={{
@@ -133,7 +133,7 @@ const SearchResultsList = ({
const shouldShowPlaybacks =
(!selected && (playbacks.length > 0 || playbacksLoading)) ||
selected === "Playbacks";
selected === "Playback";
const shouldShowUsers =
(!selected && (users.length > 0 || usersLoading)) || selected === "Profils";
+21 -3
View File
@@ -11,6 +11,7 @@ import { useRoute } from "@react-navigation/native";
import { BlurView } from "expo-blur";
import { Image as ExpoImage } from "expo-image";
import GradientButton from "../components/GradientButton";
import CreditAmount from "../components/CreditAmount";
import Page from "../layouts/Page";
import { background, subBadges } from "../assets";
import { Palette, gutters } from "../styles";
@@ -137,9 +138,16 @@ function SubscriptionCard({ plan, selected, onSelect }) {
) : null}
</View>
{coinsPerMonth !== null ? (
<Text style={styles.coinsPerMonth}>
{`+${coinsPerMonth} crédits / mois`}
</Text>
<View style={styles.coinsPerMonthRow}>
<CreditAmount
value={coinsPerMonth}
showPlus
textStyle={styles.coinsPerMonth}
iconSize={16}
accessibilityLabel={`+${coinsPerMonth} pièces par mois`}
/>
<Text style={styles.coinsPerMonthSuffix}>/ mois</Text>
</View>
) : null}
</View>
) : null}
@@ -782,11 +790,21 @@ const styles = StyleSheet.create({
alignItems: "baseline",
gap: 8,
},
coinsPerMonthRow: {
flexDirection: "row",
alignItems: "center",
gap: 4,
},
coinsPerMonth: {
fontFamily: FONT_FAMILY.InterMedium,
fontSize: 13,
color: Palette.primary,
},
coinsPerMonthSuffix: {
fontFamily: FONT_FAMILY.InterMedium,
fontSize: 13,
color: Palette.primary,
},
priceValue: {
fontFamily: FONT_FAMILY.InterBold,
fontSize: 22,
+62 -2
View File
@@ -7,7 +7,7 @@ import React, {
useRef,
useState,
} from "react";
import { Text, View } from "react-native";
import { Text, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import Svg, { Circle } from "react-native-svg";
import alert from "../../components/Alert";
@@ -30,6 +30,11 @@ const log =
? (...args) => console.log(LOG_PREFIX, ...args)
: () => {};
const CAMERA_FACING_OPTIONS = [
{ label: "Avant", value: "front" },
{ label: "Arrière", value: "back" },
];
const RecordPlayback = ({ route }) => {
const { top, bottom } = useSafeAreaInsets();
const { project } = route.params || {};
@@ -63,6 +68,7 @@ const RecordPlayback = ({ route }) => {
const [isRecording, setIsRecording] = useState(false);
const [showProgress, setShowProgress] = useState(false);
const [progressInfo, setProgressInfo] = useState({ pos: 0, dur: 0 });
const [cameraFacing, setCameraFacing] = useState("front");
// Musique
const songUrl = project?.songUrl || null;
@@ -612,7 +618,7 @@ const RecordPlayback = ({ route }) => {
<CameraView
ref={cameraRef}
style={{ flex: 1 }}
facing="front"
facing={cameraFacing}
mode="video"
mute
>
@@ -626,6 +632,14 @@ const RecordPlayback = ({ route }) => {
>
<MusicLandHeader progress={9} onPressBack={goBack} />
<View style={{ marginTop: 12, alignItems: "flex-end" }}>
<CameraFacingSelector
value={cameraFacing}
onChange={setCameraFacing}
disabled={!permissionsGranted || isPreparing || isRecording}
/>
</View>
<View style={{ flex: 1, marginTop: 11 }}>
{/* Overlay de compte à rebours : on affiche 5→1 pour éviter l'effet visuel à 1 */}
{isPreparing && countdown >= 1 && !showProgress && (
@@ -836,3 +850,49 @@ const KaraokeLines = ({ lines = [], currentLineIdx = -1 }) => {
</View>
);
};
const CameraFacingSelector = ({ value = "front", onChange, disabled = false }) => {
return (
<View
style={{
flexDirection: "row",
backgroundColor: "rgba(0,0,0,0.35)",
borderRadius: 999,
padding: 4,
gap: 4,
opacity: disabled ? 0.6 : 1,
}}
pointerEvents={disabled ? "none" : "auto"}
>
{CAMERA_FACING_OPTIONS.map((option) => {
const isActive = option.value === value;
return (
<TouchableOpacity
key={option.value}
activeOpacity={0.8}
onPress={() => {
if (typeof onChange === "function") onChange(option.value);
}}
disabled={isActive}
style={{
paddingVertical: 6,
paddingHorizontal: 14,
borderRadius: 999,
backgroundColor: isActive ? Palette.white : "transparent",
}}
>
<Text
style={{
color: isActive ? Palette.black : Palette.white,
fontFamily: FONT_FAMILY.InterMedium,
fontSize: 14,
}}
>
{option.label}
</Text>
</TouchableOpacity>
);
})}
</View>
);
};
+107 -19
View File
@@ -1,6 +1,12 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Text, View } from "react-native";
import { background } from "../../assets";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Image, Pressable, Text, View } from "react-native";
import { background, icons } from "../../assets";
import BorderGradientButton from "../../components/BorderGradientButton";
import GradientButton from "../../components/GradientButton";
import MusicLandHeader from "../../components/MusicLandHeader";
@@ -32,14 +38,22 @@ const RecordedPlayback = ({ route }) => {
const songUrl = project?.songUrl || null;
console.log("video uri is : ", videoUri);
// AUDIO PLAYER (expo-audio → seconds)
const audioPlayer = useSharedAudioPlayer(songUrl ? { uri: songUrl } : undefined, {
id: project?.id ? `recorded-${project.id}` : songUrl ? `recorded-${songUrl}` : undefined,
const audioPlayer = useSharedAudioPlayer(
songUrl ? { uri: songUrl } : undefined,
{
id: project?.id
? `recorded-${project.id}`
: songUrl
? `recorded-${songUrl}`
: undefined,
title: typeof project?.title === "string" ? project.title : "Sans titre",
artist: typeof project?.userName === "string" ? project.userName : "MusicLand",
artist:
typeof project?.userName === "string" ? project.userName : "MusicLand",
artwork: project?.coverUrl || null,
coverUrl: project?.coverUrl || null,
metadata: { projectId: project?.id, screen: "RecordedPlayback" },
});
},
);
// Optionnel: si tu veux quand même un rendu vidéo sur web si tu as une source
const videoElRef = useRef(null);
@@ -149,8 +163,11 @@ const RecordedPlayback = ({ route }) => {
};
const wasPlayingRef = useRef(false);
const onSeekStart = async () => {
const isSeekingRef = useRef(false);
const onSeekStart = useCallback(async () => {
try {
if (isSeekingRef.current) return;
isSeekingRef.current = true;
wasPlayingRef.current = !!audioPlayer?.playing;
playbackEndedRef.current = false;
if (audioPlayer?.playing) await audioPlayer.pause?.();
@@ -158,27 +175,74 @@ const RecordedPlayback = ({ route }) => {
videoElRef.current.pause();
}
} catch {}
};
const onSeekEnd = async () => {
}, [audioPlayer]);
const onSeekEnd = useCallback(async () => {
try {
if (!isSeekingRef.current) return;
isSeekingRef.current = false;
if (wasPlayingRef.current) {
if (audioPlayer) await audioPlayer.play?.();
if (videoElRef.current && videoUri)
videoElRef.current.play().catch(() => {});
}
} catch {}
};
}, [audioPlayer, videoUri]);
const handleTogglePlayback = useCallback(async () => {
try {
const duration = Number(progress?.durS || 0);
const position = Number(progress?.posS || 0);
const isAtEnd = duration > 0 && duration - position < 0.35;
const isCurrentlyPlaying = !!audioPlayer?.playing;
if (isCurrentlyPlaying) {
playbackEndedRef.current = false;
await stopPlayback();
return;
}
if (isAtEnd) {
if (audioPlayer) await audioPlayer.seekTo?.(0);
if (videoElRef.current && videoUri) {
videoElRef.current.currentTime = 0;
}
}
playbackEndedRef.current = false;
if (songUrl && audioPlayer) {
await audioPlayer.play?.();
}
if (videoElRef.current && videoUri) {
videoElRef.current.muted = true;
videoElRef.current.play().catch(() => {});
}
} catch {}
}, [audioPlayer, songUrl, stopPlayback, progress, videoUri]);
return (
<Page backgroundImg={background.playbackBG2} headerType="NONE">
<MusicLandHeader progress={19} onPressBack={goBack} />
<View
style={{ flex: 1, paddingTop: 12, gap: 14, paddingBottom: gutters * 2 }}
<Page
containerStyle={{
alignItems: "none",
}}
backgroundImg={background.playbackBG2}
headerType="NONE"
>
<MusicLandHeader
progress={19}
onPressBack={goBack}
style={{
marginBottom: 40,
}}
/>
<View
style={{
width: "100%",
}}
>
<View style={{ flex: 1, gap: 22, alignItems: "center" }}>
{/* Bloc vidéo optionnel si jamais tu as un videoUri sur web */}
{videoUri ? (
<View
<Pressable
onPress={handleTogglePlayback}
style={{
width: isWeb ? WEB_PREVIEW_WIDTH : "80%",
maxWidth: "100%",
@@ -203,7 +267,28 @@ const RecordedPlayback = ({ route }) => {
}}
controls={false}
/>
{!progress.playing && (
<View
pointerEvents="none"
style={{
position: "absolute",
left: "50%",
top: "50%",
transform: [{ translateX: -36 }, { translateY: -36 }],
width: 72,
height: 72,
borderRadius: 36,
alignItems: "center",
justifyContent: "center",
backgroundColor: "rgba(10, 5, 24, 0.75)",
borderWidth: 1,
borderColor: "#F94697",
}}
>
<Image source={icons.play} style={{ width: 26, height: 26 }} />
</View>
)}
</Pressable>
) : (
// Placeholder quand pas de vidéo sur web
<View
@@ -230,8 +315,11 @@ const RecordedPlayback = ({ route }) => {
width: isWeb ? WEB_PREVIEW_WIDTH : "80%",
maxWidth: "100%",
alignSelf: "center",
marginTop: 6,
alignItems: "center",
}}
>
<View style={{ width: "100%" }}>
<Slider
value={fmtSeconds(progress.posS)} // mm:ss (seconds)
maxValue={fmtSeconds(progress.durS)} // mm:ss (seconds)
@@ -243,14 +331,15 @@ const RecordedPlayback = ({ route }) => {
/>
</View>
</View>
</View>
<View
style={{
width: isWeb ? WEB_PREVIEW_WIDTH : "80%",
maxWidth: "100%",
alignSelf: "center",
marginTop: 4,
gap: 12,
marginTop: 50,
}}
>
<GradientButton
@@ -273,7 +362,6 @@ const RecordedPlayback = ({ route }) => {
}}
/>
</View>
</View>
</Page>
);
};
+65 -8
View File
@@ -9,6 +9,7 @@ import React, {
import {
FlatList,
Image,
Pressable,
StyleSheet,
View,
useWindowDimensions,
@@ -85,7 +86,7 @@ const Playbacks = () => {
loadMore?.();
}
},
[playbacks?.length, loadMore]
[playbacks?.length, loadMore],
);
const syncBackgroundVideo = useCallback(({ currentTime, isPlaying }) => {
@@ -193,7 +194,7 @@ const Playbacks = () => {
handleActiveIndexChange(idx);
} catch (_e) {}
},
[windowHeight, handleActiveIndexChange]
[windowHeight, handleActiveIndexChange],
);
const onScroll = useCallback(
@@ -204,7 +205,7 @@ const Playbacks = () => {
handleActiveIndexChange(idx);
} catch (_e) {}
},
[windowHeight, handleActiveIndexChange]
[windowHeight, handleActiveIndexChange],
);
// programmatic jump to focus item
@@ -228,7 +229,7 @@ const Playbacks = () => {
offset: windowHeight * index,
index,
}),
[windowHeight]
[windowHeight],
);
const total = playbacks.length;
@@ -272,6 +273,47 @@ const Playbacks = () => {
return `${activeIndex + 1}/${total}`;
}, [total, activeIndex, hasMore, loading]);
const scrollToPlayback = useCallback(
(direction) => {
if (!direction || !listRef.current) return;
const totalItems = playbacks.length;
if (totalItems <= 0) return;
const targetIndex = Math.max(
0,
Math.min(activeIndex + direction, totalItems - 1),
);
if (targetIndex === activeIndex) {
if (direction > 0 && hasMore) {
loadMore?.();
}
return;
}
const targetOffset = targetIndex * windowHeight;
try {
listRef.current.scrollToOffset({
offset: targetOffset,
animated: true,
});
} catch (_e) {
try {
listRef.current.scrollToIndex({
index: targetIndex,
animated: true,
});
} catch (__e) {}
}
handleActiveIndexChange(targetIndex);
},
[
activeIndex,
handleActiveIndexChange,
hasMore,
loadMore,
playbacks.length,
windowHeight,
],
);
return (
<View style={styles.screen}>
{activeVideoUrl ? (
@@ -324,7 +366,13 @@ const Playbacks = () => {
scrollEventThrottle={16}
/>
{showIndicators && (
<View style={styles.indicatorContainer} pointerEvents="none">
<View style={styles.indicatorContainer} pointerEvents="box-none">
<Pressable
onPress={() => scrollToPlayback(-1)}
hitSlop={12}
disabled={!canScrollUp}
style={styles.indicatorArrowTouch}
>
<Image
source={icons.chevronDown}
resizeMode="contain"
@@ -334,6 +382,7 @@ const Playbacks = () => {
!canScrollUp && styles.indicatorArrowDisabled,
]}
/>
</Pressable>
<View style={styles.indicatorDotsWrapper}>
{indicatorItems.map((item, index) => (
<View
@@ -357,6 +406,12 @@ const Playbacks = () => {
{/* {indicatorLabel ? (
<Text style={styles.indicatorLabel}>{indicatorLabel}</Text>
) : null} */}
<Pressable
onPress={() => scrollToPlayback(1)}
hitSlop={12}
disabled={!canScrollDown}
style={styles.indicatorArrowTouch}
>
<Image
source={icons.chevronDown}
resizeMode="contain"
@@ -365,6 +420,7 @@ const Playbacks = () => {
!canScrollDown && styles.indicatorArrowDisabled,
]}
/>
</Pressable>
</View>
)}
</View>
@@ -377,11 +433,8 @@ export default Playbacks;
const styles = StyleSheet.create({
screen: {
flex: 1,
// overscrollBehavior: "none",
// position: "relative",
},
backgroundVideoWrapper: {
// ...StyleSheet.absoluteFillObject,
height: "100%",
width: "100%",
position: "absolute",
@@ -437,6 +490,10 @@ const styles = StyleSheet.create({
tintColor: Palette.white,
opacity: 0.8,
},
indicatorArrowTouch: {
paddingVertical: 6,
paddingHorizontal: 8,
},
indicatorArrowUp: {
transform: [{ rotate: "180deg" }],
},
+2 -2
View File
@@ -26,14 +26,14 @@ const DownloadPrices = () => {
price: "6,99€",
},
{
title: `3 ${action === "playback" ? "Playbacks" : "chansons"}`,
title: `3 ${action === "playback" ? "Playback" : "chansons"}`,
description: `Ce ${
action === "playback" ? "Playback" : "morceau"
} et les 2 prochains que tu crées`,
price: "12,99€",
},
{
title: `5 ${action === "playback" ? "Playbacks" : "chansons"}`,
title: `5 ${action === "playback" ? "Playback" : "chansons"}`,
description: `Ce ${
action === "playback" ? "Playback" : "morceau"
} et les 4 prochains que tu crées`,
+99 -113
View File
@@ -1,59 +1,101 @@
import { View, Text, Image } from "react-native";
import React, { useState } from "react";
import React, { useCallback, useMemo } from "react";
import Page from "../../layouts/Page";
import MusicLandHeader from "../../components/MusicLandHeader";
import GradientButton from "../../components/GradientButton";
import { background, img } from "../../assets";
import { goBack, navigate } from "../../navigation/NavigationService";
import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader";
import { responsiveHeight } from "react-native-responsive-dimensions";
import Style, { size } from "../../styles/Style";
import { size } from "../../styles/Style";
import { Palette } from "../../styles";
import { FONT_FAMILY } from "../../styles/Fonts";
import { Routes } from "../../navigation";
import { useRoute } from "@react-navigation/native";
import { useUser } from "../../providers/UserDataProvider";
const STREAM_PRICES = [
{
title: "Je diffuse ma chanson",
description: "Ce morceau uniquement",
price: "1,99€ / mois",
},
{
title: "Je diffuse 3 chansons",
description: "Ce morceau et les 2 prochains que tu crées",
price: "3,99€ / mois",
},
{
title: "Illimité",
price: "14,99€ / mois",
recommended: true,
},
];
const ACTIVE_SUBSCRIPTION_STATUSES = new Set([
"trialing",
"active",
"past_due",
"unpaid",
]);
const PLAYBACK_STREAM_PRICES = [
{
title: "Je créer et diffuse mon Playback",
description: "Ce morceau uniquement",
price: "6,99€ / mois",
},
{
title: "3 Playbacks",
description: "Ce morceau et les 2 prochains que tu crées",
price: "12,99€ / mois",
},
{
title: "Illimité",
price: "20€ / mois",
recommended: true,
},
];
const pickStatus = (value) => {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed ? trimmed.toLowerCase() : null;
};
const hasActiveSubscription = (userData) => {
if (!userData) {
return false;
}
const statusCandidates = [
pickStatus(userData?.stripeSubscriptionStatus),
pickStatus(userData?.stripeSubscription?.status),
pickStatus(userData?.stripeSubscription?.stripeSubscriptionStatus),
pickStatus(userData?.stripeSubscription?.metadata?.status),
].filter(Boolean);
if (
statusCandidates.some((status) =>
ACTIVE_SUBSCRIPTION_STATUSES.has(status),
)
) {
return true;
}
const premiumUntil = userData?.premiumUntil;
if (premiumUntil) {
const asDate =
premiumUntil?.toDate?.() ||
(premiumUntil?.seconds ? new Date(premiumUntil.seconds * 1000) : null);
if (asDate && asDate > new Date()) {
return true;
}
}
if (userData?.premium?.active) {
return true;
}
return false;
};
const StreamSong = () => {
const params = useRoute().params;
const action = params?.action;
const [selectedIndex, setSelectedIndex] = useState(null);
const DATA = action === "playback" ? PLAYBACK_STREAM_PRICES : STREAM_PRICES;
const { currentUserData = null } = useUser() || {};
const isPlaybackFlow = action === "playback";
const userHasActiveSubscription = useMemo(
() => hasActiveSubscription(currentUserData),
[currentUserData],
);
const headingText = isPlaybackFlow
? "Publier ton playback"
: "Publier ta chanson";
const descriptionText = userHasActiveSubscription
? `Ton abonnement est actif, tu peux publier ${
isPlaybackFlow ? "ce playback" : "cette chanson"
} sur MusicLand et participer au concours.`
: `Pour publier ${isPlaybackFlow ? "ton playback" : "ta chanson"} sur la plateforme, tu dois rejoindre le Club MusicLand.`;
const ctaLabel = userHasActiveSubscription
? isPlaybackFlow
? "Publier mon playback"
: "Publier ma chanson"
: "Rejoindre le Club MusicLand";
const handlePrimaryAction = useCallback(() => {
if (userHasActiveSubscription) {
navigate(Routes.SongRelease, { action });
return;
}
navigate(Routes.Payments);
}, [action, userHasActiveSubscription]);
return (
<Page
@@ -90,8 +132,7 @@ const StreamSong = () => {
textAlign: "center",
}}
>
Diffuser ta chanson{"\n"}
Tarifs
{headingText}
</Text>
<Text
style={{
@@ -101,92 +142,37 @@ const StreamSong = () => {
textAlign: "center",
}}
>
Si tu désires diffuser ta chanson ou tes chansons sur la
plateforme de MusicLand et participer au concours et être dans
le top 3 voici le tarif.
{descriptionText}
</Text>
</View>
<View style={{ gap: 10 }}>
{DATA.map((item, index) => (
<CreateLyricsHeader
key={index}
colors={
selectedIndex === index
? ["#F94697", "#7023F7"]
: [Palette.tran, Palette.tran]
}
tint={!item.recommended ? "dark" : "light"}
onPress={() => {
setSelectedIndex(index);
navigate(Routes.SongRelease, {
action,
});
}}
>
<View style={{ paddingTop: 5 }}>
<Text
style={{
fontSize: 16,
color: Palette.white,
fontFamily: FONT_FAMILY.HelveticaNeueBold,
}}
>
{item.title}
</Text>
{item.description && (
<Text
style={{
fontSize: 12,
color: Palette.white,
fontFamily: FONT_FAMILY.InterRegular,
}}
>
{item.description}
</Text>
)}
</View>
<View style={{ alignSelf: "flex-end", marginTop: 5 }}>
<Text
style={{
fontSize: 12,
color: Palette.white,
fontFamily: FONT_FAMILY.InterRegular,
</CreateLyricsHeader>
<View style={{ marginTop: 24, gap: 12 }}>
<GradientButton
title={ctaLabel}
onPress={handlePrimaryAction}
containerStyle={{
width: "100%",
maxWidth: 320,
alignSelf: "center",
}}
>
{item.price}
</Text>
</View>
{item.recommended && (
<View
style={{
paddingHorizontal: 10,
height: 26,
...Style.containerCenter,
borderRadius: 100,
position: "absolute",
backgroundColor: "#FFFFFF33",
bottom: 8,
left: 10,
}}
>
/>
{!userHasActiveSubscription && (
<Text
style={{
fontSize: 14,
color: Palette.white,
fontFamily: FONT_FAMILY.HelveticaNeueRegular,
bottom: -1,
fontFamily: FONT_FAMILY.InterRegular,
textAlign: "center",
opacity: 0.8,
}}
>
Recommandé
Rejoins le club pour débloquer la publication de tes créations et
tenter de grimper dans le classement.
</Text>
</View>
)}
</CreateLyricsHeader>
))}
</View>
</View>
</CreateLyricsHeader>
</View>
</Page>
);
};
+30 -27
View File
@@ -3,6 +3,7 @@ import React, { useCallback, useMemo, useState } from "react";
import { Alert, Platform, StyleSheet, Text, View } from "react-native";
import BorderGradientButton from "../../components/BorderGradientButton";
import CreditAmount from "../../components/CreditAmount";
import { background } from "../../assets";
import { getFunctionsClient } from "../../config/firebase";
import Page from "../../layouts/Page";
@@ -44,19 +45,6 @@ const PERIOD_LABELS = {
const PAGE_BACKGROUND_COLOR = "#303438";
const formatCoinsAmount = (value) => {
if (typeof value !== "number" || !Number.isFinite(value)) {
return null;
}
try {
return new Intl.NumberFormat("fr-FR", {
maximumFractionDigits: 0,
}).format(value);
} catch (_error) {
return `${Math.round(value)}`;
}
};
const getStatusColors = (status) => {
switch (status) {
case "trialing":
@@ -384,11 +372,6 @@ const ManageSubscription = ({ navigation }) => {
typeof coinsPerMonth === "number" && Number.isFinite(coinsPerMonth)
? Math.round(coinsPerMonth)
: null;
const coinsPerMonthLabel =
normalizedCoins !== null
? `${formatCoinsAmount(normalizedCoins)} coins`
: null;
const nextGrantTimestamp =
currentUserData?.subscriptionNextGrantAt ||
currentUserData?.subscriptionGrantNextAt ||
@@ -434,11 +417,6 @@ const ManageSubscription = ({ navigation }) => {
"Ton abonnement n'est plus actif. Tu peux souscrire à nouveau à tout moment.",
);
}
if (isAnnual && coinsPerMonthLabel) {
helperMessages.push(
`Tes crédits sont versés chaque mois (${coinsPerMonthLabel}).`,
);
}
const helperMessage = helperMessages.join("\n");
return {
@@ -462,7 +440,6 @@ const ManageSubscription = ({ navigation }) => {
currentUserData?.stripeSubscription?.subscriptionId ||
null,
coinsPerMonth: normalizedCoins,
coinsPerMonthLabel,
isAnnual,
nextGrantDate: resolvedNextGrantDate,
nextGrantLabel,
@@ -614,9 +591,15 @@ const ManageSubscription = ({ navigation }) => {
<View style={styles.detailRow}>
<Text style={styles.detailLabel}>Crédits mensuels</Text>
<Text style={styles.detailValue}>
{subscriptionInfo.coinsPerMonthLabel || "—"}
</Text>
{typeof subscriptionInfo.coinsPerMonth === "number" ? (
<CreditAmount
value={subscriptionInfo.coinsPerMonth}
textStyle={styles.detailValue}
iconSize={18}
/>
) : (
<Text style={styles.detailValue}></Text>
)}
</View>
{subscriptionInfo.isAnnual && subscriptionInfo.nextGrantLabel ? (
@@ -659,6 +642,20 @@ const ManageSubscription = ({ navigation }) => {
{subscriptionInfo.helperMessage}
</Text>
) : null}
{subscriptionInfo.isAnnual &&
typeof subscriptionInfo.coinsPerMonth === "number" ? (
<View style={styles.helperInlineRow}>
<Text style={styles.helperText}>
Tes pièces sont versées chaque mois (
</Text>
<CreditAmount
value={subscriptionInfo.coinsPerMonth}
textStyle={styles.helperText}
iconSize={14}
/>
<Text style={styles.helperText}>)</Text>
</View>
) : null}
</View>
{successMessage ? (
@@ -767,6 +764,12 @@ const styles = StyleSheet.create({
lineHeight: 18,
color: Palette.grayMid,
},
helperInlineRow: {
flexDirection: "row",
alignItems: "center",
flexWrap: "wrap",
gap: 4,
},
successMessage: {
fontFamily: FONT_FAMILY.InterMedium,
fontSize: 14,
+11 -24
View File
@@ -13,6 +13,7 @@ import Page from "../../layouts/Page";
import { useUser } from "../../providers/UserDataProvider";
import { gutters, Palette } from "../../styles";
import { FONT_FAMILY } from "../../styles/Fonts";
import CreditAmount from "../../components/CreditAmount";
const ORDER_TYPE_LABELS = {
GIFT: "Crédit offert",
@@ -21,19 +22,6 @@ const ORDER_TYPE_LABELS = {
SUBSCRIPTION: "Abonnement",
};
const formatCoins = (amount) => {
if (typeof amount !== "number" || !Number.isFinite(amount)) {
return "0";
}
try {
return new Intl.NumberFormat("fr-FR", {
maximumFractionDigits: 0,
}).format(amount);
} catch (_error) {
return `${amount}`;
}
};
const formatDateParts = (date) => {
if (!date) {
return {
@@ -133,9 +121,10 @@ const OrderHistory = () => {
? item.amount
: 0;
const isPositive = amountValue > 0;
const amountLabel = `${isPositive ? "+" : ""}${formatCoins(amountValue)} ${
Math.abs(amountValue) === 1 ? "coin" : "coins"
}`;
const amountTextStyle = [
styles.orderAmount,
isPositive ? styles.amountPositive : styles.amountNegative,
];
const { dateLabel } = formatDateParts(item.createdAt);
@@ -182,14 +171,12 @@ const OrderHistory = () => {
return (
<View style={styles.orderCard}>
<Text
style={[
styles.orderAmount,
isPositive ? styles.amountPositive : styles.amountNegative,
]}
>
{amountLabel}
</Text>
<CreditAmount
value={amountValue}
showPlus
textStyle={amountTextStyle}
iconSize={18}
/>
<Text style={styles.orderReason}>{reasonLabel}</Text>
<Text style={styles.orderTimestamp}>{dateLabel}</Text>
</View>
+2 -2
View File
@@ -508,7 +508,7 @@ const Profile = () => {
}}
>
{/*{["Chansons", "Playbacks", "Clips"].map((item, index) => (*/}
{["Chansons", "Playbacks"].map((item, index) => (
{["Chansons", "Playback"].map((item, index) => (
<Pressable
key={index}
onPress={() => onPressMenu(item)}
@@ -540,7 +540,7 @@ const Profile = () => {
</Pressable>
))}
</View>
{selected === "Playbacks" && (
{selected === "Playback" && (
<MusicListSection
data={displayedPlaybacks}
emptyText="Aucun playback pour le moment"
+13 -4
View File
@@ -1,4 +1,4 @@
import React, { useState } from "react";
import React, { useEffect, useState } from "react";
import { Image, StyleSheet, View } from "react-native";
import { ai, background } from "../../assets";
import FullscreenIntroVideo from "../../components/FullscreenIntroVideo";
@@ -8,9 +8,17 @@ import Page from "../../layouts/Page";
import { Routes } from "../../navigation";
import { goBack, navigate } from "../../navigation/NavigationService";
import { gutters } from "../../styles";
import { useUserData } from "../../providers/UserDataProvider";
import { isWeb } from "../../hooks/useLayoutType";
const Compose = () => {
const [showIntro, setShowIntro] = useState(true);
const { videos } = useUserData();
const [videoUrl, setVideoUrl] = useState(null);
useEffect(() => {
setVideoUrl(isWeb ? videos.malikWeb : videos?.malik);
}, []);
return (
<Page backgroundImg={background.studioBG2} headerType="NONE">
<Image source={ai.malik} style={styles.img} resizeMode="contain" />
@@ -36,8 +44,9 @@ const Compose = () => {
</View>
</View>
<FullscreenIntroVideo
visible={showIntro}
onClose={() => setShowIntro(false)}
visible={!!videoUrl}
url={videoUrl}
onClose={() => setVideoUrl(null)}
/>
</Page>
);
+18 -20
View File
@@ -6,7 +6,7 @@ import BorderGradientButton from "../../components/BorderGradientButton";
import GradientButton from "../../components/GradientButton";
import MusicLandHeader from "../../components/MusicLandHeader";
import AppAlert from "../../components/Alert";
import CoinIcon from "../../components/CoinIcon";
import CreditAmount from "../../components/CreditAmount";
import firebase, { getFunctionsClient } from "../../config/firebase";
import Page from "../../layouts/Page";
import { Routes } from "../../navigation";
@@ -65,16 +65,6 @@ const ComposeSong = () => {
return 0;
}, [currentUserData?.coins]);
const formattedCoinBalance = useMemo(() => {
try {
return new Intl.NumberFormat("fr-FR", {
maximumFractionDigits: 0,
}).format(coinBalance);
} catch (_error) {
return `${coinBalance}`;
}
}, [coinBalance]);
const isStepValid = useMemo(() => {
switch (selectedIndex) {
case 0:
@@ -357,19 +347,18 @@ const ComposeSong = () => {
textAlign: "center",
}}
>
Cette action coûte{" "}
Cette action coûte
</Text>
<Text
style={{
<CreditAmount
value={MUSIC_GENERATION_COIN_COST}
textStyle={{
fontSize: 16,
color: Palette.white,
fontFamily: FONT_FAMILY.InterSemiBold,
textAlign: "center",
}}
>
{MUSIC_GENERATION_COIN_COST}
</Text>
<CoinIcon size={18} />
iconSize={18}
/>
</View>
<Text
style={{
@@ -398,9 +387,18 @@ const ComposeSong = () => {
textAlign: "center",
}}
>
Solde disponible : {formattedCoinBalance}
Solde disponible :
</Text>
<CoinIcon size={16} />
<CreditAmount
value={coinBalance}
textStyle={{
fontSize: 14,
color: Palette.white,
fontFamily: FONT_FAMILY.InterMedium,
textAlign: "center",
}}
iconSize={16}
/>
</View>
</View>
<View style={{ width: "85%", alignSelf: "center", gap: 15 }}>
+18 -20
View File
@@ -11,7 +11,7 @@ import BorderGradientButton from "../../components/BorderGradientButton";
import GradientButton from "../../components/GradientButton";
import MusicLandHeader from "../../components/MusicLandHeader";
import AppAlert from "../../components/Alert";
import CoinIcon from "../../components/CoinIcon";
import CreditAmount from "../../components/CreditAmount";
import firebase, { getFunctionsClient } from "../../config/firebase";
import Page from "../../layouts/Page";
import { Routes } from "../../navigation";
@@ -68,16 +68,6 @@ const ComposeSong = () => {
return 0;
}, [currentUserData?.coins]);
const formattedCoinBalance = useMemo(() => {
try {
return new Intl.NumberFormat("fr-FR", {
maximumFractionDigits: 0,
}).format(coinBalance);
} catch (_error) {
return `${coinBalance}`;
}
}, [coinBalance]);
const isStepValid = useMemo(() => {
switch (selectedIndex) {
case 0:
@@ -386,19 +376,18 @@ const ComposeSong = () => {
textAlign: "center",
}}
>
Cette action coûte{" "}
Cette action coûte
</Text>
<Text
style={{
<CreditAmount
value={MUSIC_GENERATION_COIN_COST}
textStyle={{
fontSize: 16,
color: Palette.white,
fontFamily: FONT_FAMILY.InterSemiBold,
textAlign: "center",
}}
>
{MUSIC_GENERATION_COIN_COST}
</Text>
<CoinIcon size={18} />
iconSize={18}
/>
</View>
<Text
style={{
@@ -427,9 +416,18 @@ const ComposeSong = () => {
textAlign: "center",
}}
>
Solde disponible : {formattedCoinBalance}
Solde disponible :
</Text>
<CoinIcon size={16} />
<CreditAmount
value={coinBalance}
textStyle={{
fontSize: 14,
color: Palette.white,
fontFamily: FONT_FAMILY.InterMedium,
textAlign: "center",
}}
iconSize={16}
/>
</View>
</View>
<View style={{ width: "85%", alignSelf: "center", gap: 15 }}>
+3 -8
View File
@@ -1,8 +1,7 @@
import { BlurView } from "expo-blur";
import React, { useEffect, useRef, useState } from "react";
import { Image, Platform, Text, View } from "react-native";
import { Platform, Text, View } from "react-native";
import useMinuit from "react-native-minuit/src/hooks/useMinuit.js";
import { ai } from "../../assets";
import alert from "../../components/Alert";
import GradientButton from "../../components/GradientButton";
import ProgressBar from "../../components/ProgressBar";
@@ -275,7 +274,6 @@ const CreatingLyrics = ({ active, config, selections }) => {
>
<View
style={{
flex: 1,
borderRadius: 20,
overflow: "hidden",
backgroundColor: "#0F0C1933",
@@ -284,12 +282,9 @@ const CreatingLyrics = ({ active, config, selections }) => {
<BlurView
intensity={Platform.OS !== "ios" ? 10 : 40}
tint="dark"
style={{ flex: 1, padding: 10, paddingBottom: 20 }}
// experimentalBlurMethod={
// Platform.OS !== "ios" ? "dimezisBlurView" : "none"
// }
style={{ padding: 10, paddingBottom: 20 }}
>
<View style={{ flex: 1, justifyContent: "flex-end", gap: 20 }}>
<View style={{ justifyContent: "flex-end", gap: 20 }}>
<Text
style={{
fontSize: 22,
+16 -15
View File
@@ -1,4 +1,4 @@
import React from "react";
import React, { useEffect, useState } from "react";
import { StyleSheet, Text, View } from "react-native";
import useMinuit from "react-native-minuit/src/hooks/useMinuit.js";
import { background } from "../../assets";
@@ -12,18 +12,22 @@ import { goBack, navigate } from "../../navigation/NavigationService";
import { useUserData } from "../../providers/UserDataProvider";
import { Palette, gutters } from "../../styles";
import { FONT_FAMILY } from "../../styles/Fonts";
import FullscreenIntroVideo from "../../components/FullscreenIntroVideo.web";
const CTA_BUTTON_HEIGHT = 58;
const CTA_BUTTON_MAX_WIDTH = 520;
const WritingLyrics = () => {
const {
createNewProject,
selectedProjectId,
updateProjectData,
} = useUserData();
const { createNewProject, selectedProjectId, updateProjectData, videos } =
useUserData();
const { setIsLoading } = useMinuit();
const [videoUrl, setVideoUrl] = useState(null);
useEffect(() => {
setVideoUrl(isWeb ? videos.celineWeb : videos?.celine);
}, []);
const startWriting = React.useCallback(async () => {
try {
await setIsLoading(true);
@@ -55,17 +59,9 @@ const WritingLyrics = () => {
backgroundImg={isWeb ? background.libraryBgWeb : background.writingBG}
headerType="NONE"
>
{/* <Image source={ai.nathalie} style={styles.img} resizeMode="contain" /> */}
<MusicLandHeader onPressBack={goBack} />
<View style={styles.contentWrapper}>
<View style={styles.heroContainer}>
{/* <Text style={styles.heroTitle}>
{strings.writing.onboarding.heroTitle}
</Text>
<Text style={styles.heroSubtitle}>
{strings.writing.onboarding.heroSubtitle}
</Text> */}
</View>
<View style={styles.heroContainer}></View>
<View style={styles.ctaGroup}>
<GradientButton
size="large"
@@ -81,6 +77,11 @@ const WritingLyrics = () => {
</Text>
</View>
</View>
<FullscreenIntroVideo
url={videoUrl}
visible={!!videoUrl}
onClose={() => setVideoUrl(null)}
/>
</Page>
);
};