Refactor app flows and refresh UI components

This commit is contained in:
Jacques_tirot
2026-09-08 14:20:23 +02:00
parent 4e14a38d4e
commit 814e1d27fb
71 changed files with 1984 additions and 509 deletions
+3 -1
View File
@@ -4,8 +4,11 @@
# Node/Expo caches and build artifacts # Node/Expo caches and build artifacts
node_modules/ node_modules/
.expo/ .expo/
.vercel/
.env*
dist/ dist/
web-build/ web-build/
builds/
npm-debug.* npm-debug.*
yarn-error.log yarn-error.log
@@ -30,4 +33,3 @@ ios/Pods/
# Do NOT ignore native project roots here # Do NOT ignore native project roots here
# android/ # android/
# ios/ # ios/
+2
View File
@@ -80,3 +80,5 @@ dist/
# @end expo-cli # @end expo-cli
*.sh *.sh
.vercel
.env*
+7 -6
View File
@@ -6,12 +6,12 @@
"scheme": [ "scheme": [
"musicland", "musicland",
"com.googleusercontent.apps.305598753437-qck73beo37mi04qlautgafbpdl3ccaoh", "com.googleusercontent.apps.305598753437-qck73beo37mi04qlautgafbpdl3ccaoh",
"com.googleusercontent.apps.305598753437-t2uitr9gd7aaed59ahdvklkcsg0vngmk" "com.googleusercontent.apps.305598753437-820ot12hdlpfkvhb33lj56jp4752p3un"
], ],
"deeplinks": [ "deeplinks": [
"musicland://" "musicland://"
], ],
"version": "2026.04.08", "version": "1.0",
"icon": "./assets/icon.png", "icon": "./assets/icon.png",
"backgroundColor": "#0F0C14", "backgroundColor": "#0F0C14",
"jsEngine": "hermes", "jsEngine": "hermes",
@@ -27,6 +27,8 @@
"backgroundColor": "#000" "backgroundColor": "#000"
}, },
"ios": { "ios": {
"appleTeamId": "SD7SD54VR9",
"buildNumber": "6",
"googleServicesFile": "./config/GoogleService-Info.plist", "googleServicesFile": "./config/GoogleService-Info.plist",
"supportsTablet": true, "supportsTablet": true,
"usesAppleSignIn": true, "usesAppleSignIn": true,
@@ -35,7 +37,7 @@
"associatedDomains": [ "associatedDomains": [
"applinks:musicland-one.vercel.app" "applinks:musicland-one.vercel.app"
], ],
"bundleIdentifier": "com.omedis.musicland.ai", "bundleIdentifier": "Ai.musicland",
"infoPlist": { "infoPlist": {
"UIBackgroundModes": [ "UIBackgroundModes": [
"audio" "audio"
@@ -58,12 +60,11 @@
"usesNonExemptEncryption": false "usesNonExemptEncryption": false
}, },
"entitlements": { "entitlements": {
"aps-environment": "development", "aps-environment": "production",
"com.apple.developer.applesignin": [ "com.apple.developer.applesignin": [
"Default" "Default"
] ]
}, }
"appleTeamId": "UH4Z9T6UXN"
}, },
"android": { "android": {
"softwareKeyboardLayoutMode": "resize", "softwareKeyboardLayoutMode": "resize",
+4 -4
View File
@@ -3,9 +3,9 @@
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>CLIENT_ID</key> <key>CLIENT_ID</key>
<string>305598753437-t2uitr9gd7aaed59ahdvklkcsg0vngmk.apps.googleusercontent.com</string> <string>305598753437-820ot12hdlpfkvhb33lj56jp4752p3un.apps.googleusercontent.com</string>
<key>REVERSED_CLIENT_ID</key> <key>REVERSED_CLIENT_ID</key>
<string>com.googleusercontent.apps.305598753437-t2uitr9gd7aaed59ahdvklkcsg0vngmk</string> <string>com.googleusercontent.apps.305598753437-820ot12hdlpfkvhb33lj56jp4752p3un</string>
<key>ANDROID_CLIENT_ID</key> <key>ANDROID_CLIENT_ID</key>
<string>305598753437-2oe78um7k9fagc9cjrb9vurldn5a0a4c.apps.googleusercontent.com</string> <string>305598753437-2oe78um7k9fagc9cjrb9vurldn5a0a4c.apps.googleusercontent.com</string>
<key>API_KEY</key> <key>API_KEY</key>
@@ -15,7 +15,7 @@
<key>PLIST_VERSION</key> <key>PLIST_VERSION</key>
<string>1</string> <string>1</string>
<key>BUNDLE_ID</key> <key>BUNDLE_ID</key>
<string>com.omedis.musicland.ai</string> <string>Ai.musicland</string>
<key>PROJECT_ID</key> <key>PROJECT_ID</key>
<string>musicland-d33f9</string> <string>musicland-d33f9</string>
<key>STORAGE_BUCKET</key> <key>STORAGE_BUCKET</key>
@@ -31,6 +31,6 @@
<key>IS_SIGNIN_ENABLED</key> <key>IS_SIGNIN_ENABLED</key>
<true></true> <true></true>
<key>GOOGLE_APP_ID</key> <key>GOOGLE_APP_ID</key>
<string>1:305598753437:ios:7942793c4093dedff28274</string> <string>1:305598753437:ios:4a6410dbe02ece31f28274</string>
</dict> </dict>
</plist> </plist>
+231
View File
@@ -44,3 +44,234 @@ No actionable P0, P1, or P2 visual mismatch remains.
The in-app browser blocked local `localhost` navigation during QA, so the surrounding step-2 screen could not be captured there. The web bundle completed successfully, and the actual deterministic cover renderer was executed locally to produce the implementation evidence above. The in-app browser blocked local `localhost` navigation during QA, so the surrounding step-2 screen could not be captured there. The web bundle completed successfully, and the actual deterministic cover renderer was executed locally to produce the implementation evidence above.
final result: passed final result: passed
---
# Design QA — Recentrage du fond My Library mobile
## Evidence
- Source visual truth : `/Users/minuit/musicland/src/assets/UI/libraryBgWeb.png`
- Capture avant correction : `/var/folders/3f/mf7j26x525g3pg2xgw_y4_xh0000gn/T/screenshot_optimized_4d679451-52ae-4b7f-bad0-09b9187cf4f4.jpg`
- Capture après correction : `/var/folders/3f/mf7j26x525g3pg2xgw_y4_xh0000gn/T/screenshot_optimized_68b9e20d-4f9b-450e-9436-99fa2392e278.jpg`
- Comparaison cible/implémentation : `/private/tmp/musicland-design-qa/library-centering-target-implementation.png`
- Comparaison avant/après : `/private/tmp/musicland-design-qa/library-centering-before-after.png`
- Source : 1920 × 1080 px.
- Implémentation : 368 × 800 px sur iPhone 17 Pro Simulator, iOS 26.5.
- Normalisation : crop vertical de 497 × 1080 px centré sur le point focal réel de la pièce, puis ramené à 368 × 800 px pour la comparaison.
- État : My Library, utilisateur connecté, locale française, données réelles chargées.
## Findings
- Aucun écart P0, P1 ou P2 ne subsiste.
- Fonts and typography : les textes de l’interface et la typographie intégrée au décor restent inchangés et nets.
- Spacing and layout rhythm : le néon `WRITING SPACE`, le logo `MUSICLAND`, la fenêtre centrale et les fauteuils partagent maintenant le même axe visuel que l’écran.
- Colors and visual tokens : aucun filtre, changement de palette ou variation d’opacité n’a été ajouté.
- Image quality and asset fidelity : l’asset web 1920 × 1080 est réutilisé directement ; seul son cadrage natif est ajusté, sans recompression ni baisse de qualité.
- Copy and content : aucun texte applicatif n’a été modifié.
## Full-view Comparison Evidence
- Avant : le cadrage géométrique de l’image plaçait le point focal de la composition trop à gauche et coupait le début de `WRITING SPACE`.
- Après : le néon, le logo et le centre de la pièce sont équilibrés autour de l’axe du téléphone, tandis que le contenu et la barre de navigation restent inchangés.
## Focused-region Comparison Evidence
- La comparaison cible/implémentation montre le même crop autour du néon, de la fenêtre et des fauteuils. Aucun détail important n’est étiré ou reconstruit.
## Comparison History
1. P2 — point focal du fond décalé sur mobile.
2. Correction — ajout d’un style de fond optionnel au composant `Page` et cadrage My Library à 140 % de largeur, ancré à gauche pour recentrer le point focal.
3. Post-fix — capture iPhone 17 Pro et comparaison normalisée : aucun P0/P1/P2 restant.
## Implementation Checklist
- [x] Préserver l’asset web original.
- [x] Recentrer uniquement le cadrage mobile de My Library.
- [x] Reconstruire et relancer l’application iOS.
- [x] Vérifier visuellement l’écran authentifié.
## Follow-up Polish
- Aucun nécessaire.
final result: passed
---
# Design QA — Home, Playback et My Library (parité web/mobile)
## Evidence
- Source visual truth — carte d’accueil : `/Users/minuit/musicland/src/assets/UI/musiclandStreaming.png`
- Source visual truth — fond Playback web : `/Users/minuit/musicland/src/assets/UI/playbackBG2.png`
- Source visual truth — fond My Library web : `/Users/minuit/musicland/src/assets/UI/libraryBgWeb.png`
- Capture d’implémentation — accueil mobile : `/var/folders/3f/mf7j26x525g3pg2xgw_y4_xh0000gn/T/screenshot_optimized_af967a03-10cb-447c-87e6-e04ff9564781.jpg`
- Capture d’implémentation — Playback mobile : `/var/folders/3f/mf7j26x525g3pg2xgw_y4_xh0000gn/T/screenshot_optimized_04b867f4-fd08-45e0-9227-9424f5c278b6.jpg`
- Capture d’implémentation — My Library mobile : `/var/folders/3f/mf7j26x525g3pg2xgw_y4_xh0000gn/T/screenshot_optimized_8ad858ba-7b4a-4e12-bd9b-f4df6e5c4459.jpg`
- Comparaison ciblée — carte Streaming : `/private/tmp/musicland-design-qa/home-card-compare.png`
- Comparaison ciblée — carte Playback : `/private/tmp/musicland-design-qa/playback-card-compare.png`
- Comparaison plein écran — fond Playback : `/private/tmp/musicland-design-qa/playback-background-compare.png`
- Comparaison plein écran — fond My Library : `/private/tmp/musicland-design-qa/library-background-compare.png`
- Viewport d’implémentation : iPhone 17 Pro Simulator, capture 368 × 800 px, densité normalisée par l’outil de capture.
- Sources : carte Streaming 1920 × 1080 px, carte Playback 1920 × 1080 px, fond Playback 2048 × 2048 px, fond My Library 1920 × 1080 px.
- État : utilisateur connecté, locale française, accueil défilé jusqu’à la carte Streaming, onglets Playback et My Library chargés avec données.
## Findings
- Aucun écart P0, P1 ou P2 exploitable ne subsiste.
- Fonts and typography: la typographie intégrée aux cartes Streaming et Playback est conservée sans reconstruction. Les textes d’interface gardent la hiérarchie et les retours à la ligne existants.
- Spacing and layout rhythm: la carte Streaming conserve le ratio 16:9 et les rayons existants. La carte Playback reste alignée au centre ; le léger zoom interne retire uniquement les contours blancs encodés au bord de l’image.
- Colors and visual tokens: les arrière-plans mobiles réutilisent exactement les assets web, sans recoloration ni filtre supplémentaire.
- Image quality and asset fidelity: les images originales sont réutilisées à leur résolution source. Aucun nouvel asset compressé ou approximatif n’a été créé.
- Copy and content: aucun contenu textuel métier n’a été modifié par cette passe.
## Full-view Comparison Evidence
- Playback : la comparaison place le crop vertical de `playbackBG2.png` à gauche et le rendu mobile à droite. Les projecteurs, le plateau, le mur d’écrans et la perspective correspondent ; les éléments d’interface restent lisibles par-dessus.
- My Library : la comparaison place le crop vertical de `libraryBgWeb.png` à gauche et le rendu mobile à droite. Le néon Writing Space, les écrans, les fauteuils et la palette correspondent au fond web attendu.
- Accueil : le rendu mobile montre la carte `STREAMING MUSICLAND` à la place de l’ancienne carte Club après l’étape 4.
## Focused-region Comparison Evidence
- Carte Streaming : comparaison source/rendu à dimensions égales, avec le même logo, la même onde, le même cadrage et les mêmes couleurs.
- Carte Playback : comparaison source/rendu ciblée sur le bord de l’image. Les contours blancs visibles dans la source aux coins sont absents du rendu grâce au crop interne, sans perte perceptible sur le logo.
## Comparison History
1. Passe initiale après correction : aucune différence P0/P1/P2 ; aucune itération corrective supplémentaire requise.
## Implementation Checklist
- [x] Remplacer la carte Club de l’accueil par la carte Streaming partagée sur web et mobile.
- [x] Utiliser le fond Playback web sur mobile.
- [x] Masquer les contours blancs de la carte Playback sans dégrader l’asset.
- [x] Utiliser le fond My Library web sur mobile.
- [x] Construire le bundle web.
- [x] Construire et lancer l’app iOS dans le simulateur.
- [x] Vérifier visuellement les trois écrans dans l’état authentifié.
## Follow-up Polish
- Aucun nécessaire pour cette correction ciblée.
final result: passed
---
# Design QA — Home Step 3 Card
## Evidence
- Source visual truth: `/var/folders/3f/mf7j26x525g3pg2xgw_y4_xh0000gn/T/codex-clipboard-b493e809-ad60-4136-998c-a6a8fec6c27e.png`
- Shared implementation asset: `/Users/minuit/musicland/src/assets/icons/video.png`
- Mobile implementation screenshot: `/var/folders/3f/mf7j26x525g3pg2xgw_y4_xh0000gn/T/screenshot_optimized_ad2bf22d-a0e4-44ad-a161-76e096792cb0.jpg`
- Web implementation screenshot: `/private/tmp/musicland-step3-web-full.png`
- Focused source/web comparison: `/private/tmp/musicland-step3-reference-vs-web.png`
- Source pixels: 1328 × 748 RGBA.
- Mobile viewport: 368 × 800 pixels on the iPhone 17 Pro simulator. The card is rendered by the production `StageCard` mobile variant with `contentFit="contain"`.
- Web capture viewport: 1280 × 720 pixels. The shared asset was served by Expo Web from `src/assets/icons/video.png`.
- Browser console: no errors on the standalone shared-asset render. The app landing page still reports pre-existing Stripe provider `internal` errors, unrelated to the card asset.
- Density normalization: the focused comparison scales the source proportionally to 720 px high and places it beside the 1280 × 720 web capture. The source image data and the repository asset have identical SHA-256 hashes.
- State: authenticated Home menu, French locale, scrolled to the locked Step 3 card.
## Findings
- No actionable P0, P1, or P2 mismatches.
- Fonts and typography: the supplied `VIDEO / MUSICLAND` typography remains embedded in the raster asset and is reproduced exactly. The Step 3 label and localized description continue to use the existing Home card typography.
- Spacing and layout rhythm: the existing mobile and web `StageCard` layouts are unchanged. `contentFit="contain"` preserves the supplied aspect ratio without stretching or cropping.
- Colors and visual tokens: the dark navy panel, blue/magenta waveform, white lettering, and neon suit accents match the source asset exactly.
- Image quality and asset fidelity: the supplied RGBA PNG is used directly; no reconstruction, approximation, recompression, or generated replacement was introduced. The gray area visible behind transparency in the standalone browser image view is browser canvas chrome, not part of the app card.
- Copy and content: Step 3 keeps the localized Mr Benaï text in French and English. Step 4 keeps its localized Video-space text.
## Full-view Comparison
- The mobile Home capture confirms the new Mr Benaï visual appears in the Step 3 slot with the intended subject, waveform, logo, proportions, and rounded card edge.
- The web asset capture confirms Expo Web resolves the same 1328 × 748 shared source used by native.
## Focused-region Comparison
- The side-by-side source/web comparison shows identical subject placement, waveform geometry, typography, colors, and transparency boundaries. No focused correction was needed.
## Comparison History
- Pass 1: no P0/P1/P2 findings; no corrective iteration required.
## Implementation Checklist
- [x] Replace the shared Step 3 card asset with the supplied PNG.
- [x] Preserve the existing mobile and web card layouts.
- [x] Preserve localized Step 3 and Step 4 copy.
- [x] Verify the card on the mobile Home menu.
- [x] Verify the shared asset through the local Expo Web server.
## Follow-up Polish
- None required for this asset replacement.
final result: passed
---
# Design QA — Fonds Library et Playback web/mobile
## Evidence
- Source visual truth Library : `/Users/minuit/Library/Containers/net.whatsapp.WhatsApp/Data/tmp/documents/7E14186C-23EE-4687-A68F-19B0F8E453B4/PHOTO-2026-08-27-15-22-58.jpg`
- Source visual truth Playback : `/Users/minuit/Library/Containers/net.whatsapp.WhatsApp/Data/tmp/documents/6C989EEC-09A2-4A2B-A014-33F05E6107AC/PHOTO-2026-08-27-15-22-58.jpg`
- Assets intégrés : `/Users/minuit/musicland/src/assets/UI/libraryMainBackground.jpg` et `/Users/minuit/musicland/src/assets/UI/playbackMainBackground.jpg`
- Capture mobile Library : `/var/folders/3f/mf7j26x525g3pg2xgw_y4_xh0000gn/T/screenshot_optimized_4bb9262f-e642-483b-a7cf-368fa1e1f6c5.jpg`
- Capture mobile Playback : `/var/folders/3f/mf7j26x525g3pg2xgw_y4_xh0000gn/T/screenshot_optimized_e8f9391d-85c6-45d5-aff0-6f414714b274.jpg`
- Rendu Browser du bundle web Library : `/private/tmp/musicland-design-qa/library-web-render.png`
- Rendu Browser du bundle web Playback : `/private/tmp/musicland-design-qa/playback-web-render.png`
- Comparaison Library mobile : `/private/tmp/musicland-design-qa/library-mobile-target-implementation.png`
- Comparaison Playback mobile : `/private/tmp/musicland-design-qa/playback-mobile-target-implementation.png`
- Comparaison Library web : `/private/tmp/musicland-design-qa/library-web-target-implementation.png`
- Comparaison Playback web : `/private/tmp/musicland-design-qa/playback-web-target-implementation.png`
- Viewport mobile : iPhone 17 Pro Simulator, iOS 26.5, capture 368 × 800 px.
- Viewport web : 1280 × 720 px dans le navigateur intégré.
- Source Library : 1280 × 724 px ; source Playback : 1280 × 714 px.
- Normalisation mobile : crop `cover` centré de 333 × 724 px pour Library et 328 × 714 px pour Playback, puis mise à l’échelle 368 × 800 px.
- État mobile : utilisateur connecté, locale française, onglets Library et Playback avec données réelles.
- État web : bundle Expo exporté et assets chargés depuis le serveur local. La session du navigateur intégré est sur l’inscription, donc les pages authentifiées complètes n’y ont pas été ouvertes.
## Findings
- Aucun écart P0, P1 ou P2 sur les deux fonds.
- Fonts and typography : aucune typographie n’est recréée dans les fonds ; les textes et logos d’interface restent inchangés et lisibles.
- Spacing and layout rhythm : les deux points focaux sont centrés en portrait. Le bureau central de Library et la plateforme centrale de Playback restent alignés avec l’axe de l’écran.
- Colors and visual tokens : les palettes cyan, bleu nuit et magenta des sources sont conservées sans filtre ni recoloration.
- Image quality and asset fidelity : les fichiers fournis ont été copiés octet pour octet. Les SHA-256 des sources et des assets intégrés sont identiques. Aucun redimensionnement permanent ni recompression n’a été appliqué.
- Copy and content : aucun contenu applicatif n’a été modifié.
## Full-view Comparison Evidence
- Library mobile : le crop vertical rendu correspond au crop `cover` centré de la source ; la fenêtre, la guitare et la table restent sur le même axe.
- Playback mobile : le plafond néon, l’écran central et la plateforme correspondent au crop centré de la source.
- Web : les deux comparaisons côte à côte montrent que les fichiers exportés par Expo sont visuellement identiques aux sources fournies.
## Focused-region Comparison Evidence
- Les zones centrales critiques sont lisibles dans les comparaisons mobiles : fenêtre/bureau pour Library, hologramme/plateforme pour Playback.
- Les comparaisons web à 1280 px vérifient également les bords, la netteté et l’absence de halo ou de compression supplémentaire.
## Comparison History
1. Passe initiale après remplacement : aucune différence P0/P1/P2 ; aucune correction visuelle supplémentaire requise.
## Implementation Checklist
- [x] Copier les deux sources sans conversion.
- [x] Utiliser l’image 1 uniquement pour les écrans Library web et mobile.
- [x] Utiliser l’image 2 pour l’écran Playback partagé web et mobile.
- [x] Construire le bundle web et vérifier les deux assets dans le navigateur intégré.
- [x] Construire et lancer l’app iOS.
- [x] Ouvrir et capturer les deux onglets sur le simulateur.
- [x] Vérifier les erreurs console web : aucune erreur liée à cette modification ; seulement deux avertissements préexistants Stripe HTTP et Animated web.
## Follow-up Polish
- Aucun nécessaire pour ce remplacement d’assets.
final result: passed
+8
View File
@@ -21,9 +21,17 @@
"resourceClass": "large" "resourceClass": "large"
}, },
"ios": { "ios": {
"image": "latest",
"buildConfiguration": "Release", "buildConfiguration": "Release",
"autoIncrement": "buildNumber" "autoIncrement": "buildNumber"
} }
} }
},
"submit": {
"production": {
"ios": {
"ascAppId": "6796655773"
}
}
} }
} }
File diff suppressed because one or more lines are too long
+102 -14
View File
@@ -52,10 +52,106 @@ function basicTemplate({ title = '', content = '', button = null }) {
</body></html>` </body></html>`
} }
function welcomeTemplate({ firstName = '', lastName = '' }) { function cleanEmailName(firstName) {
const fullName = [firstName, lastName].filter(Boolean).join(' ').trim() return typeof firstName === 'string' ? firstName.replace(/[\r\n]+/g, ' ').trim() : ''
const greeting = fullName ? `Salut ${fullName},` : 'Salut,' }
return `<!doctype html><html lang="fr" style="background-color:#0b0b10"><head>
function escapeHtml(value) {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}
function getWelcomeLanguage(preferredLanguage) {
return preferredLanguage === 'en' ? 'en' : 'fr'
}
function welcomeEmailSubject({ firstName = '', preferredLanguage = 'fr' } = {}) {
const language = getWelcomeLanguage(preferredLanguage)
const cleanFirstName = cleanEmailName(firstName)
const subject = language === 'en' ? 'Welcome to Musicland' : 'Bienvenue à MusicLand'
return cleanFirstName ? `${subject}, ${cleanFirstName}` : subject
}
function welcomeTemplate({ firstName = '', preferredLanguage = 'fr' } = {}) {
const language = getWelcomeLanguage(preferredLanguage)
const cleanFirstName = cleanEmailName(firstName)
const safeFirstName = escapeHtml(cleanFirstName)
const greeting =
language === 'en'
? safeFirstName
? `Hi ${safeFirstName},`
: 'Hi,'
: safeFirstName
? `Salut ${safeFirstName},`
: 'Salut,'
const content =
language === 'en'
? `
<p style="margin:0 0 16px;font-size:16px;line-height:1.6;color:#ffffff!important">
That’s it, you’ve got your pass to MusicLand. Welcome to the land of song!
</p>
<p style="margin:0 0 16px;font-size:16px;line-height:1.6;color:#ffffff!important">
You’re about to embark on an extraordinary adventure!
</p>
<p style="margin:0 0 16px;font-size:16px;line-height:1.6;color:#ffffff!important">
You’ll write your own lyrics, add a melody, shoot your music video… and upload your song to the MusicLand streaming platform.
</p>
<p style="margin:0 0 10px;font-size:16px;line-height:1.6;color:#ffffff!important">
Three areas await you:
</p>
<ul style="margin:0 0 16px;padding-left:22px;font-size:16px;line-height:1.6;color:#ffffff!important">
<li style="margin-bottom:8px">The Songwriting Area: Céline will help you write original lyrics.</li>
<li style="margin-bottom:8px">The Studio: Malik will turn it into beautiful music.</li>
<li>The Video Studio: With Théo, you’ll direct your first music video.</li>
</ul>
<p style="margin:0 0 16px;font-size:16px;line-height:1.6;color:#ffffff!important">
And don’t worry, you don’t need to know how to sing, you don’t need any musical or technical skills, and you’ll be blown away by the result.
</p>
<p style="margin:0 0 16px;font-size:16px;line-height:1.6;color:#ffffff!important">
See you soon in the halls of MusicLand,
</p>
<p style="margin:0 0 24px;font-size:16px;line-height:1.6;color:#ffffff!important">
And… Feel the good vibe!
</p>
<p style="margin:0;font-size:16px;line-height:1.6;color:#ffffff!important">
<strong>The MusicLand Team</strong>
</p>`
: `
<p style="margin:0 0 16px;font-size:16px;line-height:1.6;color:#ffffff!important">
Ça y est : tu as ton pass pour MusicLand. Bienvenue au pays de la chanson !
</p>
<p style="margin:0 0 16px;font-size:16px;line-height:1.6;color:#ffffff!important">
Tu vas vivre une aventure extraordinaire!
</p>
<p style="margin:0 0 16px;font-size:16px;line-height:1.6;color:#ffffff!important">
Tu vas écrire tes paroles, habiller ta mélodie, tourner ton clip… et déposer <strong>ta</strong> chanson sur la plateforme de streaming de MusicLand.
</p>
<p style="margin:0 0 10px;font-size:16px;line-height:1.6;color:#ffffff!important">
Trois espaces t'attendent:
</p>
<ul style="margin:0 0 16px;padding-left:22px;font-size:16px;line-height:1.6;color:#ffffff!important">
<li style="margin-bottom:8px">L'espace Écriture: Céline va t’aider à composer un texte original.</li>
<li style="margin-bottom:8px">Le studio: Malik va le sublimer en musique.</li>
<li>Le studio vidéo: avec Théo tu réaliseras ton premier clip vidéo.</li>
</ul>
<p style="margin:0 0 16px;font-size:16px;line-height:1.6;color:#ffffff!important">
Et rassure toi, tu n’as pas besoin de savoir chanter, tu n'as besoin d’aucune compétence musicale ou technique et tu vas être bluffé par le résultat.
</p>
<p style="margin:0 0 16px;font-size:16px;line-height:1.6;color:#ffffff!important">
À très vite dans les allées de MusicLand,
</p>
<p style="margin:0 0 24px;font-size:16px;line-height:1.6;color:#ffffff!important">
And… Feel the good vibe!
</p>
<p style="margin:0;font-size:16px;line-height:1.6;color:#ffffff!important">
<strong>L'équipe MusicLand</strong>
</p>`
return `<!doctype html><html lang="${language}" style="background-color:#0b0b10"><head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="color-scheme" content="dark" /> <meta name="color-scheme" content="dark" />
<meta name="supported-color-schemes" content="dark" /> <meta name="supported-color-schemes" content="dark" />
@@ -86,16 +182,7 @@ function welcomeTemplate({ firstName = '', lastName = '' }) {
/> />
</div> </div>
<p style="margin:0 0 12px;font-size:16px;letter-spacing:0.2px;color:#ffffff!important">${greeting}</p> <p style="margin:0 0 12px;font-size:16px;letter-spacing:0.2px;color:#ffffff!important">${greeting}</p>
<h1 style="margin:0 0 16px;font-size:28px;line-height:1.2;color:#ffffff!important">Bienvenue sur MusicLand</h1> ${content}
<p style="margin:0 0 16px;font-size:16px;line-height:1.6;color:#ffffff!important">
Nous sommes ravis de t'accueillir dans la communauté des artistes et passionnés de MusicLand.
</p>
<p style="margin:0 0 16px;font-size:15px;line-height:1.6;color:#ffffff!important">
Explore les playlists collaboratives, dépose tes projets et découvre ce que prépare la scène émergente.
</p>
<p style="margin:0;font-size:15px;line-height:1.6;color:#ffffff!important">
L'équipe MusicLand
</p>
</td> </td>
</tr> </tr>
</table> </table>
@@ -107,4 +194,5 @@ function welcomeTemplate({ firstName = '', lastName = '' }) {
} }
exports.basicTemplate = basicTemplate exports.basicTemplate = basicTemplate
exports.welcomeEmailSubject = welcomeEmailSubject
exports.welcomeTemplate = welcomeTemplate exports.welcomeTemplate = welcomeTemplate
+54 -9
View File
@@ -7,8 +7,46 @@ const { setTimeout } = require('timers/promises')
// --- CONFIGURATION --- // --- CONFIGURATION ---
// Plus intelligente que le Flash original, ultra rapide, et stable sur l'API. // Plus intelligente que le Flash original, ultra rapide, et stable sur l'API.
const TEXT_MODEL_NAME = 'gemini-flash-latest' const TEXT_MODEL_NAME = 'gemini-3.5-flash'
const TEXT_FALLBACK_MODEL_NAME = 'gemini-3.1-flash-lite'
const IMAGE_MODEL_NAME = 'gemini-3-pro-image' const IMAGE_MODEL_NAME = 'gemini-3-pro-image'
const TEXT_GENERATION_MAX_ATTEMPTS = 3
const TEXT_GENERATION_RETRY_DELAY_MS = 1000
const isRetryableTextGenerationError = (error) => {
const status = Number(error?.status || error?.response?.status)
return [429, 500, 502, 503, 504].includes(status)
}
const generateTextWithRetry = async (ai, request, operation) => {
let lastError = null
for (let attempt = 1; attempt <= TEXT_GENERATION_MAX_ATTEMPTS; attempt += 1) {
const modelName =
attempt === TEXT_GENERATION_MAX_ATTEMPTS ? TEXT_FALLBACK_MODEL_NAME : TEXT_MODEL_NAME
try {
return await ai.generate({
...request,
model: googleAI.model(modelName),
})
} catch (error) {
lastError = error
const shouldRetry =
isRetryableTextGenerationError(error) && attempt < TEXT_GENERATION_MAX_ATTEMPTS
if (!shouldRetry) throw error
const delayMs = TEXT_GENERATION_RETRY_DELAY_MS * 2 ** (attempt - 1)
console.warn(
`🔄 [${operation}] ${modelName} indisponible (${error?.status || 'réseau'}), nouvelle tentative ${attempt + 1}/${TEXT_GENERATION_MAX_ATTEMPTS} dans ${delayMs}ms`
)
await setTimeout(delayMs)
}
}
throw lastError
}
// --- SINGLETON PATTERN (WARM START) --- // --- SINGLETON PATTERN (WARM START) ---
// On stocke l'instance en dehors de la fonction pour la réutiliser // On stocke l'instance en dehors de la fonction pour la réutiliser
@@ -39,15 +77,18 @@ exports.generateAI = async ({ system = '', prompt = '', schema }) => {
const startedAt = Date.now() const startedAt = Date.now()
try { try {
const { output } = await ai.generate({ const { output } = await generateTextWithRetry(
model: googleAI.model(TEXT_MODEL_NAME), ai,
{
system, system,
prompt, prompt,
output: { schema }, output: { schema },
config: { config: {
temperature: 0.7, // Créativité équilibrée temperature: 0.7, // Créativité équilibrée
}, },
}) },
'generateAI'
)
return output return output
} catch (error) { } catch (error) {
@@ -97,7 +138,7 @@ exports.analyseLyrics = async ({ title = '', lyrics }) => {
flagged: z.boolean().describe('Vrai si le contenu nécessite un avertissement.'), flagged: z.boolean().describe('Vrai si le contenu nécessite un avertissement.'),
blocked: z.boolean().describe('Vrai UNIQUEMENT si violation grave (Haine, Violence réelle).'), blocked: z.boolean().describe('Vrai UNIQUEMENT si violation grave (Haine, Violence réelle).'),
score: z.number().min(0).max(1).describe('Score de risque (0=Sûr, 1=Dangereux).'), score: z.number().min(0).max(1).describe('Score de risque (0=Sûr, 1=Dangereux).'),
reasons: z.array(z.string()).describe('Liste concise des raisons.'), reasons: z.array(z.string()).max(5).describe('Liste concise des raisons.'),
excerpts: z excerpts: z
.array( .array(
z.object({ z.object({
@@ -106,7 +147,7 @@ exports.analyseLyrics = async ({ title = '', lyrics }) => {
severity: z.string(), severity: z.string(),
}) })
) )
.max(10), .max(3),
success: z.boolean(), success: z.boolean(),
}) })
@@ -133,12 +174,14 @@ ${lyricsText}
const startedAt = Date.now() const startedAt = Date.now()
try { try {
const { output } = await ai.generate({ const { output } = await generateTextWithRetry(
model: googleAI.model(TEXT_MODEL_NAME), ai,
{
system, system,
prompt: userPrompt, prompt: userPrompt,
output: { schema: moderationSchema }, output: { schema: moderationSchema },
config: { config: {
temperature: 0,
// Paramètres de sécurité permissifs pour laisser l'IA voir et juger le contenu // Paramètres de sécurité permissifs pour laisser l'IA voir et juger le contenu
safetySettings: [ safetySettings: [
{ category: 'HARM_CATEGORY_HATE_SPEECH', threshold: 'BLOCK_NONE' }, { category: 'HARM_CATEGORY_HATE_SPEECH', threshold: 'BLOCK_NONE' },
@@ -153,7 +196,9 @@ ${lyricsText}
{ category: 'HARM_CATEGORY_HARASSMENT', threshold: 'BLOCK_NONE' }, { category: 'HARM_CATEGORY_HARASSMENT', threshold: 'BLOCK_NONE' },
], ],
}, },
}) },
'analyseLyrics'
)
if (!output) throw new Error("Échec de l'analyse de modération.") if (!output) throw new Error("Échec de l'analyse de modération.")
+7 -3
View File
@@ -92,9 +92,13 @@ const buildCircularMask = (size) => {
async function loadCoverIdentity(project) { async function loadCoverIdentity(project) {
const artistName = normalizeArtistName(project.userName) const artistName = normalizeArtistName(project.userName)
const userId = typeof project.userId === 'string' ? project.userId.trim() : '' const userId = typeof project.userId === 'string' ? project.userId.trim() : ''
const centerImageURL =
typeof project.coverCenterImageURL === 'string' && project.coverCenterImageURL.trim()
? project.coverCenterImageURL.trim()
: null
if (!userId) { if (!userId) {
return { artistName, profilePictureURL: null } return { artistName, profilePictureURL: centerImageURL }
} }
try { try {
@@ -102,14 +106,14 @@ async function loadCoverIdentity(project) {
const userData = userSnapshot.exists ? userSnapshot.data() : null const userData = userSnapshot.exists ? userSnapshot.data() : null
return { return {
artistName: normalizeArtistName(project.userName || userData?.userName), artistName: normalizeArtistName(project.userName || userData?.userName),
profilePictureURL: userData?.profilePictureURL || null, profilePictureURL: centerImageURL || userData?.profilePictureURL || null,
} }
} catch (error) { } catch (error) {
logger.warn('[Cover] Unable to load profile picture', { logger.warn('[Cover] Unable to load profile picture', {
projectId: project.id, projectId: project.id,
error: error.message, error: error.message,
}) })
return { artistName, profilePictureURL: null } return { artistName, profilePictureURL: centerImageURL }
} }
} }
+6 -2
View File
@@ -17,6 +17,7 @@ const projectsRef = db.collection('projects')
const REGION = process.env.FIREBASE_REGION || 'europe-west1' const REGION = process.env.FIREBASE_REGION || 'europe-west1'
const HEYGEN_BASE_URL = 'https://api.heygen.com/v3' const HEYGEN_BASE_URL = 'https://api.heygen.com/v3'
const HEYGEN_REQUEST_TIMEOUT_MS = 45_000
const PLAYBACK_STATUS = { const PLAYBACK_STATUS = {
GENERATING: 'GENERATING', GENERATING: 'GENERATING',
DRAFT_READY: 'DRAFT_READY', DRAFT_READY: 'DRAFT_READY',
@@ -65,9 +66,10 @@ const buildPlaybackReadyNotificationId = ({ projectId, videoId }) => {
return `playback-ready-${digest}` return `playback-ready-${digest}`
} }
const getHeygenHeaders = () => ({ const getHeygenHeaders = (idempotencyKey = null) => ({
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-Api-Key': getSecretValue(HEYGEN_API_KEY, 'HEYGEN_API_KEY'), 'X-Api-Key': getSecretValue(HEYGEN_API_KEY, 'HEYGEN_API_KEY'),
...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}),
}) })
const getProjectSnapshot = async ({ projectId, uid }) => { const getProjectSnapshot = async ({ projectId, uid }) => {
@@ -120,6 +122,7 @@ const findProjectForWebhook = async ({ callbackId, videoId }) => {
const fetchCanonicalVideo = async (videoId) => { const fetchCanonicalVideo = async (videoId) => {
const response = await axios.get(`${HEYGEN_BASE_URL}/videos/${videoId}`, { const response = await axios.get(`${HEYGEN_BASE_URL}/videos/${videoId}`, {
headers: getHeygenHeaders(), headers: getHeygenHeaders(),
timeout: HEYGEN_REQUEST_TIMEOUT_MS,
}) })
return response?.data?.data || null return response?.data?.data || null
@@ -210,7 +213,8 @@ exports.startPlaybackGeneration = onCall(
callback_id: callbackId, callback_id: callbackId,
}, },
{ {
headers: getHeygenHeaders(), headers: getHeygenHeaders(callbackId),
timeout: HEYGEN_REQUEST_TIMEOUT_MS,
} }
) )
+17 -9
View File
@@ -5,7 +5,7 @@ const { SUNO_API_BASE, SUNO_TIMESTAMPED_LYRICS_PATH } = require('../config/suno'
const { GEMINI_API_KEY, SUNO_API_KEY } = require('../config/secrets') const { GEMINI_API_KEY, SUNO_API_KEY } = require('../config/secrets')
const axios = require('axios') const axios = require('axios')
const { FieldValue } = require('firebase-admin/firestore') const { FieldValue } = require('firebase-admin/firestore')
const { REGION, refList } = require('../index') const { refList } = require('../index')
// --- CONSTANTES DE STRUCTURE --- // --- CONSTANTES DE STRUCTURE ---
// On garde ces mappings car ils sont utiles pour normaliser l'input utilisateur // On garde ces mappings car ils sont utiles pour normaliser l'input utilisateur
@@ -59,13 +59,22 @@ const STRUCTURE_ALIASES = {
soft_transition: 'transition_douce', soft_transition: 'transition_douce',
} }
const LYRICS_FUNCTION_OPTIONS = { const LYRICS_FUNCTION_BASE_OPTIONS = {
region: REGION,
cors: true, cors: true,
invoker: 'public', invoker: 'public',
secrets: [GEMINI_API_KEY], secrets: [GEMINI_API_KEY],
} }
const LYRICS_GENERATION_OPTIONS = {
...LYRICS_FUNCTION_BASE_OPTIONS,
region: 'us-central1',
}
const LYRICS_MODERATION_OPTIONS = {
...LYRICS_FUNCTION_BASE_OPTIONS,
region: ['us-central1', 'europe-west1'],
}
// --- UTILITAIRES --- // --- UTILITAIRES ---
const normalizeStructureValue = (value) => { const normalizeStructureValue = (value) => {
@@ -144,7 +153,7 @@ const buildModerationBrief = ({
// --- GENERATION DE PAROLES (MAIN) --- // --- GENERATION DE PAROLES (MAIN) ---
exports.generateLyrics = onCall(LYRICS_FUNCTION_OPTIONS, async ({ auth = {}, data = {} }) => { exports.generateLyrics = onCall(LYRICS_GENERATION_OPTIONS, async ({ auth = {}, data = {} }) => {
try { try {
const { const {
lang: rawLang, lang: rawLang,
@@ -204,9 +213,8 @@ exports.generateLyrics = onCall(LYRICS_FUNCTION_OPTIONS, async ({ auth = {}, dat
} }
} catch (moderationError) { } catch (moderationError) {
if (moderationError instanceof HttpsError) throw moderationError if (moderationError instanceof HttpsError) throw moderationError
console.error('⚠️ Moderation check error (fail open)', moderationError) console.error('⚠️ Moderation check error (fail closed)', moderationError)
// On continue si l'appel modération fail (fail open) ou on throw (fail closed) selon ta politique. // En cas d'indisponibilité du pré-contrôle, on bloque la génération par sécurité.
// Ici je fail closed par sécurité pour une app publique.
throw new HttpsError('internal', 'Vérification de sécurité indisponible.') throw new HttpsError('internal', 'Vérification de sécurité indisponible.')
} }
@@ -364,7 +372,7 @@ const softenModerationDecision = (rawResult = {}) => {
} }
} }
exports.analyseLyricsToxicity = onCall(LYRICS_FUNCTION_OPTIONS, async ({ data = {} }) => { exports.analyseLyricsToxicity = onCall(LYRICS_MODERATION_OPTIONS, async ({ data = {} }) => {
try { try {
const requestSchema = z.object({ const requestSchema = z.object({
title: z.string().optional(), title: z.string().optional(),
@@ -440,7 +448,7 @@ async function getSunoTimestamps(projectId) {
{ {
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: `Bearer ${SUNO_API_KEY.value()}`, Authorization: `Bearer ${SUNO_API_KEY.value().trim()}`,
}, },
timeout: 30000, timeout: 30000,
} }
+105 -8
View File
@@ -2,7 +2,7 @@ const { onCall, onRequest, HttpsError } = require('firebase-functions/v2/https')
const axios = require('axios') const axios = require('axios')
const admin = require('firebase-admin') const admin = require('firebase-admin')
const { FieldValue } = require('firebase-admin/firestore') const { FieldValue } = require('firebase-admin/firestore')
const { logger } = require('firebase-functions/logger') const logger = require('firebase-functions/logger')
const { pipeline } = require('stream/promises') const { pipeline } = require('stream/promises')
const { randomUUID } = require('crypto') const { randomUUID } = require('crypto')
const { ALERT_TYPE, refList } = require('../index') const { ALERT_TYPE, refList } = require('../index')
@@ -516,8 +516,8 @@ exports.generateMusic = onCall({ secrets: [SUNO_API_KEY] }, async ({ data = {} }
// 3. Détection du genre vocal pour le param vocalGender (optimisation v3.5) // 3. Détection du genre vocal pour le param vocalGender (optimisation v3.5)
// On regarde si on trouve 'male' ou 'female' dans les tags générés // On regarde si on trouve 'male' ou 'female' dans les tags générés
let vGender = null let vGender = null
if (optimizedStyle.includes('Female')) vGender = 'female' if (optimizedStyle.includes('Female')) vGender = 'f'
else if (optimizedStyle.includes('Male')) vGender = 'male' else if (optimizedStyle.includes('Male')) vGender = 'm'
console.log('🎵 [Suno Optimisation] Result:', { console.log('🎵 [Suno Optimisation] Result:', {
style: optimizedStyle, style: optimizedStyle,
@@ -542,14 +542,36 @@ exports.generateMusic = onCall({ secrets: [SUNO_API_KEY] }, async ({ data = {} }
const response = await axios.post(`${SUNO_API_BASE}${SUNO_API_PATH}`, payload, { const response = await axios.post(`${SUNO_API_BASE}${SUNO_API_PATH}`, payload, {
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: `Bearer ${SUNO_API_KEY.value()}`, Authorization: `Bearer ${SUNO_API_KEY.value().trim()}`,
}, },
}) })
const parsed = response.data const parsed = response.data
if (parsed?.code !== 200) {
throw new Error(parsed?.msg || 'Suno a refusé la demande de génération.')
}
const taskId = sanitizeField(parsed?.data?.taskId)
if (!taskId) {
throw new Error("Suno n'a pas retourné d'identifiant de génération.")
}
const projectId = sanitizeField(data?.projectId)
if (projectId) {
await refList.projects.doc(projectId).set(
{
sunoTaskId: taskId,
musicStatus: 'GENERATING',
musicError: FieldValue.delete(),
generationStartAt: FieldValue.serverTimestamp(),
updatedAt: FieldValue.serverTimestamp(),
},
{ merge: true }
)
}
return { return {
success: !!parsed?.data?.taskId, success: true,
request: { request: {
model: payload.model, model: payload.model,
title: safeTitle, title: safeTitle,
@@ -570,19 +592,94 @@ exports.generateMusic = onCall({ secrets: [SUNO_API_KEY] }, async ({ data = {} }
} }
}) })
// GET STATUS (inchangé mais inclus pour complétude) const reconcileSunoStatus = async ({ taskId, statusData }) => {
const status = statusData?.status
const failedStatuses = new Set([
'CREATE_TASK_FAILED',
'GENERATE_AUDIO_FAILED',
'CALLBACK_EXCEPTION',
'SENSITIVE_WORD_ERROR',
])
if (status === 'SUCCESS') {
const tracks = Array.isArray(statusData?.response?.sunoData)
? statusData.response.sunoData
: []
const audioUrls = tracks
.map((track) => track?.audioUrl)
.filter((url) => typeof url === 'string' && url.trim())
.slice(0, 2)
if (!audioUrls.length) throw new Error('SUNO_AUDIO_URLS_MISSING')
const { projectId, projectData, projectRef } = await fetchProjectByTaskId(taskId)
const existingUrls = sanitizeMusicUrls(projectData?.musicUrls)
if (projectData?.musicStatus === 'GENERATED' && existingUrls.length) {
return { reconciled: true, projectId, musicUrls: existingUrls }
}
const { userId, projectTitle } = formatProjectMeta(projectData)
const storedUrls = await saveTracksToStorage(audioUrls, {
userId,
projectId,
taskId,
})
if (!storedUrls.length) throw new Error('SUNO_AUDIO_STORAGE_FAILED')
const musicUrls = await mergeMusicUrls(projectRef, storedUrls)
if (userId) {
try {
await sendNotification({
sender: 'SYSTEM',
receiver: userId,
receiverCollection: 'users',
title: 'Musique prête',
message: `Ta musique pour "${projectTitle}" est prête.`,
data: {
type: ALERT_TYPE?.MUSIC_GENERATION_SUCCESS,
projectId,
projectTitle,
musicUrls,
taskId,
},
})
} catch (error) {
console.error('Music status notification failed', error)
}
}
return { reconciled: true, projectId, musicUrls }
}
if (failedStatuses.has(status)) {
const { projectId } = await fetchProjectByTaskId(taskId)
return {
reconciled: false,
failed: true,
projectId,
musicUrls: [],
errorMessage: statusData?.errorMessage || 'La génération Suno a échoué.',
}
}
return { reconciled: false, failed: false, projectId: null, musicUrls: [] }
}
// GET STATUS — secours lorsque le callback Suno n'arrive pas.
exports.getSunoStatus = onCall({ secrets: [SUNO_API_KEY] }, async ({ data = {} }) => { exports.getSunoStatus = onCall({ secrets: [SUNO_API_KEY] }, async ({ data = {} }) => {
const { taskId } = data const { taskId } = data
if (!taskId) throw new Error('TaskId manquant') if (!taskId) throw new Error('TaskId manquant')
try { try {
const response = await axios.get(`${SUNO_API_BASE}${SUNO_STATUS_PATH}?taskId=${taskId}`, { const response = await axios.get(`${SUNO_API_BASE}${SUNO_STATUS_PATH}?taskId=${taskId}`, {
headers: { Authorization: `Bearer ${SUNO_API_KEY.value()}` }, headers: { Authorization: `Bearer ${SUNO_API_KEY.value().trim()}` },
timeout: 30000, timeout: 30000,
}) })
const statusData = response.data?.data
const reconciliation = await reconcileSunoStatus({ taskId, statusData })
return { return {
success: true, success: true,
taskId, taskId,
data: response.data?.data || response.data, status: statusData?.status || null,
...reconciliation,
} }
} catch (error) { } catch (error) {
if (taskId.includes('test')) if (taskId.includes('test'))
+7 -8
View File
@@ -5,7 +5,7 @@ const { refList } = require('../index')
const { ORDER_TYPES, createOrderDocument } = require('./helpers/orders') const { ORDER_TYPES, createOrderDocument } = require('./helpers/orders')
const { deleteFolder } = require('../helpers/firebase') const { deleteFolder } = require('../helpers/firebase')
const { Resend } = require('resend') const { Resend } = require('resend')
const { welcomeTemplate } = require('../helpers/email') const { welcomeEmailSubject, welcomeTemplate } = require('../helpers/email')
const { RESEND_API_KEY } = require('../config/secrets') const { RESEND_API_KEY } = require('../config/secrets')
const { onRequest } = require('firebase-functions/https') const { onRequest } = require('firebase-functions/https')
@@ -19,7 +19,6 @@ const getResendClient = () => {
} }
const WELCOME_EMAIL_FROM = 'MusicLand <musicland@musicland.ai>' const WELCOME_EMAIL_FROM = 'MusicLand <musicland@musicland.ai>'
const WELCOME_EMAIL_SUBJECT = 'Bienvenue sur MusicLand'
const USER_CREATED_OPTIONS = { const USER_CREATED_OPTIONS = {
document: 'users/{userID}', document: 'users/{userID}',
secrets: [RESEND_API_KEY], secrets: [RESEND_API_KEY],
@@ -33,12 +32,12 @@ exports.testWelcomMail = onRequest({ secrets: [RESEND_API_KEY] }, async (req, re
try { try {
const targetEmail = req.query.email || 'tdtomthomas@gmail.com' const targetEmail = req.query.email || 'tdtomthomas@gmail.com'
const firstName = req.query.firstName || 'Toto' const firstName = req.query.firstName || 'Toto'
const lastName = req.query.lastName || 'Test' const preferredLanguage = req.query.preferredLanguage === 'en' ? 'en' : 'fr'
const { data, error } = await getResendClient().emails.send({ const { data, error } = await getResendClient().emails.send({
from: WELCOME_EMAIL_FROM, from: WELCOME_EMAIL_FROM,
to: [targetEmail], to: [targetEmail],
subject: WELCOME_EMAIL_SUBJECT, subject: welcomeEmailSubject({ firstName, preferredLanguage }),
html: welcomeTemplate({ firstName, lastName }), html: welcomeTemplate({ firstName, preferredLanguage }),
}) })
if (error) { if (error) {
console.log('Failed to send welcome email:', error) console.log('Failed to send welcome email:', error)
@@ -54,7 +53,7 @@ exports.testWelcomMail = onRequest({ secrets: [RESEND_API_KEY] }, async (req, re
exports.onUserCreated = onDocumentCreated(USER_CREATED_OPTIONS, async (event) => { exports.onUserCreated = onDocumentCreated(USER_CREATED_OPTIONS, async (event) => {
try { try {
const { email = '', firstName = '', lastName = '' } = event?.data?.data() || {} const { email = '', firstName = '', preferredLanguage = 'fr' } = event?.data?.data() || {}
const userId = event?.params?.userID const userId = event?.params?.userID
@@ -79,8 +78,8 @@ exports.onUserCreated = onDocumentCreated(USER_CREATED_OPTIONS, async (event) =>
const { data, error } = await getResendClient().emails.send({ const { data, error } = await getResendClient().emails.send({
from: WELCOME_EMAIL_FROM, from: WELCOME_EMAIL_FROM,
to: [email], to: [email],
subject: WELCOME_EMAIL_SUBJECT, subject: welcomeEmailSubject({ firstName, preferredLanguage }),
html: welcomeTemplate({ firstName, lastName }), html: welcomeTemplate({ firstName, preferredLanguage }),
}) })
if (error) { if (error) {
console.log('Failed to send welcome email:', error) console.log('Failed to send welcome email:', error)
@@ -0,0 +1,21 @@
{
"applinks": {
"apps": [],
"details": [
{
"appID": "SD7SD54VR9.Ai.musicland",
"paths": [
"/music/*",
"/musics/*",
"/playback/*",
"/playbacks/*",
"/task/*",
"/tasks/*"
]
}
]
},
"webcredentials": {
"apps": ["SD7SD54VR9.Ai.musicland"]
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 657 KiB

After

Width:  |  Height:  |  Size: 634 KiB

+11 -2
View File
@@ -220,17 +220,19 @@ export const background = {
productionBG2, productionBG2,
playbackBG, playbackBG,
playbackBG2, playbackBG2,
playbackMainBackground: require('./UI/playbackMainBackground.jpg'),
libraryBG, libraryBG,
writingMobileBackground, writingMobileBackground,
libraryBgWeb: require('./UI/libraryBgWeb.png'), libraryBgWeb: require('./UI/libraryBgWeb.png'),
libraryMainBackground: require('./UI/libraryMainBackground.jpg'),
libraryBG2, libraryBG2,
libraryBG2Web: require('./UI/libraryBG2Web.png'), libraryBG2Web: require('./UI/libraryBG2Web.png'),
profileBG, profileBG,
profileBgWeb: require('./UI/profileBgWeb.png'), profileBgWeb: require('./UI/profileBgWeb.png'),
hitParadeBG, hitParadeBG,
hitParadeBG2: require('./UI/hitparadeBG2.jpg'), hitParadeBG2: require('./UI/hitparadeBG2.jpg'),
playbackWeb: require('./UI/playbackWeb.png'), playbackWeb: require('./UI/videoSpaceBackground.jpg'),
playbackMobile: require('./UI/playbackMobile.png'), playbackMobile: require('./UI/videoSpaceBackground.jpg'),
homeBG, homeBG,
homeBGWeb: require('./UI/homeBGWeb.png'), homeBGWeb: require('./UI/homeBGWeb.png'),
loginBgWeb: require('./UI/loginBgWeb.png'), loginBgWeb: require('./UI/loginBgWeb.png'),
@@ -251,6 +253,11 @@ export const videos = {
Platform.OS === 'web' ? require('./video/testVideoWeb.mp4') : require('./video/testVideo.mp4'), Platform.OS === 'web' ? require('./video/testVideoWeb.mp4') : require('./video/testVideo.mp4'),
club: require('./video/club.mp4'), club: require('./video/club.mp4'),
entryJingle: require('./video/entry-jingle.mp4'), entryJingle: require('./video/entry-jingle.mp4'),
languageBackground: require('./video/language-background-web.mp4'),
writingSpaceIntroFr: require('./video/writing-space-intro-fr.mp4'),
writingSpaceIntroEn: require('./video/writing-space-intro-en.mp4'),
videoSpaceIntroFr: require('./video/video-space-intro-fr.mp4'),
videoSpaceIntroEn: require('./video/video-space-intro-en.mp4'),
welcomeFr: require('./video/welcome-fr.mp4'), welcomeFr: require('./video/welcome-fr.mp4'),
welcomeEn: require('./video/welcome-en.mp4'), welcomeEn: require('./video/welcome-en.mp4'),
} }
@@ -264,6 +271,8 @@ export const img = {
goodVibe, goodVibe,
musiclandClub, musiclandClub,
musiclandPlaybackCover, musiclandPlaybackCover,
musiclandPlayback: require('./UI/musiclandPlayback.png'),
musiclandStreaming: require('./UI/musiclandStreaming.png'),
} }
export const cardsImg = { export const cardsImg = {
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+8 -4
View File
@@ -3,7 +3,7 @@ import { VideoView, useVideoPlayer } from 'expo-video'
import React, { useEffect, useMemo, useState } from 'react' import React, { useEffect, useMemo, useState } from 'react'
import { Pressable, StyleSheet, Text, View } from 'react-native' import { Pressable, StyleSheet, Text, View } from 'react-native'
const BackgroundVideo = ({ source }) => { const BackgroundVideo = ({ source, silent = false }) => {
const [muted, setMuted] = useState(false) const [muted, setMuted] = useState(false)
const resolvedSource = useMemo( const resolvedSource = useMemo(
() => (source ? (typeof source === 'string' ? { uri: source } : source) : null), () => (source ? (typeof source === 'string' ? { uri: source } : source) : null),
@@ -20,9 +20,9 @@ const BackgroundVideo = ({ source }) => {
return return
} }
player.muted = muted player.muted = silent || muted
player.play() player.play()
}, [muted, player, source]) }, [muted, player, silent, source])
if (!source) { if (!source) {
return null return null
@@ -37,6 +37,7 @@ const BackgroundVideo = ({ source }) => {
style={StyleSheet.absoluteFill} style={StyleSheet.absoluteFill}
/> />
<View pointerEvents="none" style={styles.scrim} /> <View pointerEvents="none" style={styles.scrim} />
{!silent ? (
<Pressable <Pressable
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={muted ? 'Activer le son' : 'Couper le son'} accessibilityLabel={muted ? 'Activer le son' : 'Couper le son'}
@@ -48,8 +49,11 @@ const BackgroundVideo = ({ source }) => {
size={20} size={20}
color="#FFFFFF" color="#FFFFFF"
/> />
<Text style={styles.soundButtonText}>{muted ? 'Activer le son' : 'Couper le son'}</Text> <Text style={styles.soundButtonText}>
{muted ? 'Activer le son' : 'Couper le son'}
</Text>
</Pressable> </Pressable>
) : null}
</View> </View>
) )
} }
+9 -5
View File
@@ -4,7 +4,7 @@ import { Pressable, StyleSheet, Text, View } from 'react-native'
import { resolveMediaUri } from '../utils/resolveMediaUri' import { resolveMediaUri } from '../utils/resolveMediaUri'
const BackgroundVideo = ({ source, soundButtonStyle = null }) => { const BackgroundVideo = ({ source, soundButtonStyle = null, silent = false }) => {
const videoRef = useRef(null) const videoRef = useRef(null)
const [uri, setUri] = useState(null) const [uri, setUri] = useState(null)
const [muted, setMuted] = useState(false) const [muted, setMuted] = useState(false)
@@ -63,11 +63,11 @@ const BackgroundVideo = ({ source, soundButtonStyle = null }) => {
return return
} }
video.muted = muted video.muted = silent || muted
if (video.paused) { if (video.paused) {
video.play()?.catch?.(() => {}) video.play()?.catch?.(() => {})
} }
}, [muted]) }, [muted, silent])
if (!uri) { if (!uri) {
return null return null
@@ -82,10 +82,11 @@ const BackgroundVideo = ({ source, soundButtonStyle = null }) => {
playsInline playsInline
autoPlay autoPlay
loop loop
muted={muted} muted={silent || muted}
controls={false} controls={false}
/> />
<View pointerEvents="none" style={styles.scrim} /> <View pointerEvents="none" style={styles.scrim} />
{!silent ? (
<Pressable <Pressable
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={muted ? 'Activer le son' : 'Couper le son'} accessibilityLabel={muted ? 'Activer le son' : 'Couper le son'}
@@ -102,8 +103,11 @@ const BackgroundVideo = ({ source, soundButtonStyle = null }) => {
size={20} size={20}
color="#FFFFFF" color="#FFFFFF"
/> />
<Text style={styles.soundButtonText}>{muted ? 'Activer le son' : 'Couper le son'}</Text> <Text style={styles.soundButtonText}>
{muted ? 'Activer le son' : 'Couper le son'}
</Text>
</Pressable> </Pressable>
) : null}
</View> </View>
) )
} }
+2 -11
View File
@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useRef } from 'react' import React, { useCallback, useEffect, useRef } from 'react'
import { View, Pressable, Text } from 'react-native' import { Pressable, StyleSheet, Text, View } from 'react-native'
import { VideoView, useVideoPlayer } from 'expo-video' import { VideoView, useVideoPlayer } from 'expo-video'
import { videos } from '../assets' import { videos } from '../assets'
import { Portal } from '@gorhom/portal' import { Portal } from '@gorhom/portal'
@@ -96,7 +96,6 @@ const FullscreenIntroVideo = ({
pointerEvents="box-none" pointerEvents="box-none"
style={{ style={{
position: 'absolute', position: 'absolute',
paddingHorizontal: 10,
top: 0, top: 0,
left: 0, left: 0,
right: 0, right: 0,
@@ -111,15 +110,7 @@ const FullscreenIntroVideo = ({
player={player} player={player}
nativeControls={false} nativeControls={false}
contentFit={contentFit} contentFit={contentFit}
height="100%" style={StyleSheet.absoluteFill}
width="100%"
// style={{
// position: "absolute",
// top: 0,
// bottom: 0,
// left: 24,
// right: 24,
// }}
/> />
{allowSkip ? ( {allowSkip ? (
<Pressable <Pressable
+30
View File
@@ -0,0 +1,30 @@
import React, { memo } from 'react'
import { Image, StyleSheet, View } from 'react-native'
const SpaceLogoCard = ({ source, accessibilityLabel, imageStyle = null }) => (
<View style={styles.card}>
<Image
source={source}
accessibilityLabel={accessibilityLabel}
resizeMode="contain"
style={[styles.image, imageStyle]}
/>
</View>
)
const styles = StyleSheet.create({
card: {
width: '100%',
maxWidth: 280,
aspectRatio: 16 / 9,
alignSelf: 'center',
borderRadius: 20,
overflow: 'hidden',
},
image: {
width: '100%',
height: '100%',
},
})
export default memo(SpaceLogoCard)
+169
View File
@@ -0,0 +1,169 @@
import DateTimePicker from '@react-native-community/datetimepicker'
import React, { useEffect, useState } from 'react'
import { Modal, Platform, Pressable, StyleSheet, Text, View } from 'react-native'
import { Palette } from '../../styles'
import { FONT_FAMILY } from '../../styles/Fonts'
const getDefaultBirthDate = () => {
const date = new Date()
date.setFullYear(date.getFullYear() - 18)
return date
}
const NativeDateModal = ({ visible, onClose, onValidate, initialDate, language = 'fr' }) => {
const [selectedDate, setSelectedDate] = useState(initialDate || getDefaultBirthDate())
useEffect(() => {
if (visible) {
setSelectedDate(initialDate || getDefaultBirthDate())
}
}, [initialDate, visible])
const handleChange = (event, date) => {
if (Platform.OS === 'android') {
if (event.type === 'dismissed') {
onClose()
return
}
if (date) {
onValidate(date)
}
onClose()
return
}
if (date) {
setSelectedDate(date)
}
}
if (Platform.OS === 'android') {
return visible ? (
<DateTimePicker
testID="dateTimePicker"
value={initialDate || getDefaultBirthDate()}
mode="date"
display="default"
onChange={handleChange}
maximumDate={new Date()}
/>
) : null
}
return (
<Modal
visible={visible}
transparent
animationType="fade"
presentationStyle="overFullScreen"
onRequestClose={onClose}
>
<View style={styles.overlay}>
<Pressable
accessibilityRole="button"
accessibilityLabel="Fermer le sélecteur de date"
style={StyleSheet.absoluteFill}
onPress={onClose}
/>
<View style={styles.card}>
<Text style={styles.title}>Date de naissance</Text>
<DateTimePicker
testID="dateTimePicker"
value={selectedDate}
mode="date"
display="spinner"
locale={language === 'en' ? 'en-GB' : 'fr-FR'}
maximumDate={new Date()}
themeVariant="dark"
onChange={handleChange}
style={styles.picker}
/>
<View style={styles.actions}>
<Pressable
accessibilityRole="button"
onPress={onClose}
style={({ pressed }) => [styles.button, styles.cancelButton, pressed && styles.pressed]}
>
<Text style={styles.cancelText}>Annuler</Text>
</Pressable>
<Pressable
accessibilityRole="button"
onPress={() => {
onValidate(selectedDate)
onClose()
}}
style={({ pressed }) => [styles.button, styles.validateButton, pressed && styles.pressed]}
>
<Text style={styles.validateText}>Valider</Text>
</Pressable>
</View>
</View>
</View>
</Modal>
)
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 24,
backgroundColor: 'rgba(0, 0, 0, 0.72)',
},
card: {
width: '100%',
maxWidth: 360,
padding: 20,
borderRadius: 20,
backgroundColor: Palette.lightPurple,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.14)',
},
title: {
color: Palette.white,
fontFamily: FONT_FAMILY.InterSemiBold,
fontSize: 18,
textAlign: 'center',
marginBottom: 6,
},
picker: {
width: '100%',
height: 180,
},
actions: {
flexDirection: 'row',
gap: 12,
marginTop: 8,
},
button: {
flex: 1,
minHeight: 46,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 12,
},
cancelButton: {
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.28)',
},
validateButton: {
backgroundColor: Palette.white,
},
cancelText: {
color: Palette.white,
fontFamily: FONT_FAMILY.InterSemiBold,
fontSize: 15,
},
validateText: {
color: Palette.darkPurple,
fontFamily: FONT_FAMILY.InterSemiBold,
fontSize: 15,
},
pressed: {
opacity: 0.72,
},
})
export default NativeDateModal
+4 -2
View File
@@ -9,8 +9,10 @@ import { Platform } from 'react-native'
const functionsInstances = {} const functionsInstances = {}
const emulatorConfigured = {} const emulatorConfigured = {}
const emulatorHost = Platform.OS === 'web' ? 'localhost' : '192.168.1.60' const emulatorHost =
const USE_FUNCTIONS_EMULATOR = __DEV__ process.env.EXPO_PUBLIC_FIREBASE_EMULATOR_HOST ||
(Platform.OS === 'web' ? 'localhost' : '127.0.0.1')
const USE_FUNCTIONS_EMULATOR = __DEV__ && process.env.EXPO_PUBLIC_USE_FIREBASE_EMULATORS === 'true'
const configureFunctionsEmulator = (instance, regionKey = 'us-central1') => { const configureFunctionsEmulator = (instance, regionKey = 'us-central1') => {
const shouldUseEmulator = USE_FUNCTIONS_EMULATOR && instance?.useEmulator const shouldUseEmulator = USE_FUNCTIONS_EMULATOR && instance?.useEmulator
+4 -3
View File
@@ -88,13 +88,14 @@ const ensureUserDocument = async (user, overrides = {}, isNewUser = false) => {
payload.createdAt = firebase.firestore.FieldValue.serverTimestamp() payload.createdAt = firebase.firestore.FieldValue.serverTimestamp()
if (user.email) payload.email = user.email if (user.email) payload.email = user.email
payload.emailNotifications = true payload.emailNotifications = true
if (isNewUser) {
payload.registrationFlowRequired = true
}
} else if (typeof existingData?.emailNotifications === 'undefined') { } else if (typeof existingData?.emailNotifications === 'undefined') {
payload.emailNotifications = true payload.emailNotifications = true
} }
if (isNewUser) {
payload.registrationFlowRequired = false
}
const overrideFirstName = const overrideFirstName =
typeof overrides?.firstName === 'string' ? overrides.firstName.trim() : null typeof overrides?.firstName === 'string' ? overrides.firstName.trim() : null
const overrideLastName = const overrideLastName =
+1 -1
View File
@@ -72,7 +72,7 @@ export const BottomTabScreen = () => {
name={Routes.Library} name={Routes.Library}
component={Library} component={Library}
options={{ options={{
tabBarLabel: 'Librairie', tabBarLabel: 'My library',
headerShown: false, headerShown: false,
tabBarShowLabel: true, tabBarShowLabel: true,
tabBarIcon: ({ focused }) => renderIcon(tabs.albums, focused), tabBarIcon: ({ focused }) => renderIcon(tabs.albums, focused),
+67 -7
View File
@@ -7,10 +7,24 @@ import { useGlobal } from 'reactn'
import firebase, { import firebase, {
arrayUnion, arrayUnion,
notificationsRef, notificationsRef,
projectsRef,
serverTimestamp, serverTimestamp,
usersRef, usersRef,
} from '../config/firebase' } from '../config/firebase'
import useDataFromRef from '../hooks/useDataFromRef' import useDataFromRef from '../hooks/useDataFromRef'
import { Routes } from '../navigation'
import { navigate } from '../navigation/NavigationService'
if (Platform.OS !== 'web') {
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldPlaySound: true,
shouldSetBadge: true,
shouldShowBanner: true,
shouldShowList: true,
}),
})
}
export const NotificationContext = createContext(null) export const NotificationContext = createContext(null)
@@ -19,11 +33,14 @@ export default function NotificationProvider({ children }) {
const [uid] = useGlobal('currentUID') const [uid] = useGlobal('currentUID')
const [pendingData, setPendingData] = useGlobal('pendingData') const [pendingData, setPendingData] = useGlobal('pendingData')
const [activeRouteName] = useGlobal('activeRouteName')
const [notifInit, setNotifInit] = useState(false) const [notifInit, setNotifInit] = useState(false)
const [allowNotifications, setAllowNotifications] = useState(false) const [allowNotifications, setAllowNotifications] = useState(false)
const notificationListener = useRef() const notificationListener = useRef()
const responseListener = useRef() const responseListener = useRef()
const handledResponseId = useRef(null)
const handledNavigationKey = useRef(null)
const notificationsQuery = useMemo(() => { const notificationsQuery = useMemo(() => {
if (!uid) { if (!uid) {
@@ -149,24 +166,29 @@ export default function NotificationProvider({ children }) {
return return
} }
notificationListener.current = Notifications.addNotificationReceivedListener( notificationListener.current = Notifications.addNotificationReceivedListener(
async (notification) => { (notification) => {
console.log('Notification received', notification) console.log('Notification received', notification)
await Notifications.scheduleNotificationAsync(notification?.request)
} }
) )
responseListener.current = Notifications.addNotificationResponseReceivedListener( const handleNotificationResponse = async (response) => {
async (response) => { const requestId = response?.notification?.request?.identifier || null
if (requestId && handledResponseId.current === requestId) return
if (requestId) handledResponseId.current = requestId
console.log('Notification response', response) console.log('Notification response', response)
if (response?.actionIdentifier === 'expo.modules.notifications.actions.DEFAULT') { if (response?.actionIdentifier === Notifications.DEFAULT_ACTION_IDENTIFIER) {
const { data } = response.notification.request.content const data = response?.notification?.request?.content?.data || null
if (data) { if (data) {
console.log('Notification data', data) console.log('Notification data', data)
await setPendingData(data) await setPendingData(data)
} }
} }
} }
)
Notifications.getLastNotificationResponseAsync().then(handleNotificationResponse)
responseListener.current =
Notifications.addNotificationResponseReceivedListener(handleNotificationResponse)
return () => { return () => {
Notifications.removeNotificationSubscription(notificationListener.current) Notifications.removeNotificationSubscription(notificationListener.current)
@@ -174,6 +196,44 @@ export default function NotificationProvider({ children }) {
} }
}, [allowNotifications, setPendingData]) }, [allowNotifications, setPendingData])
useEffect(() => {
const notificationType = pendingData?.type
const projectId = pendingData?.projectId
if (
!uid ||
!activeRouteName ||
notificationType !== 'PLAYBACK_GENERATION_SUCCESS' ||
!projectId
) {
return
}
const navigationKey = `${notificationType}:${projectId}`
if (handledNavigationKey.current === navigationKey) return
handledNavigationKey.current = navigationKey
const openGeneratedPlayback = async () => {
try {
const projectSnap = await projectsRef.doc(projectId).get()
if (!projectSnap.exists) return
navigate(Routes.PlaybackAiStatus, {
project: {
...projectSnap.data(),
id: projectSnap.id,
},
})
} catch (error) {
console.log('openGeneratedPlayback error:', error)
} finally {
await setPendingData(null)
}
}
openGeneratedPlayback()
}, [activeRouteName, pendingData, setPendingData, uid])
async function registerForPushNotificationsAsync() { async function registerForPushNotificationsAsync() {
try { try {
const isWeb = Platform.OS === 'web' const isWeb = Platform.OS === 'web'
+17 -1
View File
@@ -10,6 +10,14 @@ import { UserDataContext } from './UserDataProvider'
const APP_SCHEME = 'musicland' const APP_SCHEME = 'musicland'
const APP_PATH_PREFIX = 'app' const APP_PATH_PREFIX = 'app'
const APP_STORE_FALLBACK_URL = appleAppStoreUrl const APP_STORE_FALLBACK_URL = appleAppStoreUrl
const NATIVE_DEEP_LINK_RESOURCES = new Set([
'music',
'musics',
'playback',
'playbacks',
'task',
'tasks',
])
const normalizePath = (rawPath = '') => { const normalizePath = (rawPath = '') => {
if (!rawPath) return '' if (!rawPath) return ''
@@ -27,6 +35,14 @@ const buildAppSchemeUrl = (rawPath, queryParams = {}) => {
return `${APP_SCHEME}://${fullPath}${queryString ? `?${queryString}` : ''}` return `${APP_SCHEME}://${fullPath}${queryString ? `?${queryString}` : ''}`
} }
const shouldOpenNativeApp = (rawPath) => {
const normalizedPath = normalizePath(rawPath)
if (normalizedPath.toLowerCase() === 'link') return true
const [resource, identifier] = normalizedPath.split('/').filter(Boolean)
return !!identifier && NATIVE_DEEP_LINK_RESOURCES.has(resource?.toLowerCase())
}
const UniversalLinkProvider = ({ children }) => { const UniversalLinkProvider = ({ children }) => {
const { currentUID } = useContext(UserDataContext) const { currentUID } = useContext(UserDataContext)
const { isFullyLoaded } = useContext(SplashAnimationContext) const { isFullyLoaded } = useContext(SplashAnimationContext)
@@ -137,7 +153,7 @@ const UniversalLinkProvider = ({ children }) => {
const isIOSBrowser = const isIOSBrowser =
/iPad|iPhone|iPod/.test(userAgent) || /iPad|iPhone|iPod/.test(userAgent) ||
(/Macintosh/.test(userAgent) && navigator.maxTouchPoints && navigator.maxTouchPoints > 2) (/Macintosh/.test(userAgent) && navigator.maxTouchPoints && navigator.maxTouchPoints > 2)
if (isIOSBrowser) { if (isIOSBrowser && shouldOpenNativeApp(path)) {
try { try {
const canOpen = await Linking.canOpenURL(appSchemeURL) const canOpen = await Linking.canOpenURL(appSchemeURL)
if (canOpen) { if (canOpen) {
+5 -5
View File
@@ -12,10 +12,10 @@ import { checkIfPasswordIsStrongEnough, handleFirebaseError } from '../actions/s
import firebase, { usersRef } from '../config/firebase' import firebase, { usersRef } from '../config/firebase'
import Page from '../layouts/Page' import Page from '../layouts/Page'
import { isWeb } from '../hooks/useLayoutType' import { isWeb } from '../hooks/useLayoutType'
import { Routes } from '../navigation'
import { reset } from '../navigation/NavigationService' import { reset } from '../navigation/NavigationService'
import { Palette } from '../styles' import { Palette } from '../styles'
import { FONT_FAMILY } from '../styles/Fonts' import { FONT_FAMILY } from '../styles/Fonts'
import { getMainMenuReset } from '../utils/registrationFlow'
const CreatePassword = () => { const CreatePassword = () => {
const route = useRoute() const route = useRoute()
@@ -115,14 +115,14 @@ const CreatePassword = () => {
...(birthDate ? { birthDate } : {}), ...(birthDate ? { birthDate } : {}),
...(country?.code ? { countryCode: country.code } : {}), ...(country?.code ? { countryCode: country.code } : {}),
...(country?.name ? { countryName: country.name } : {}), ...(country?.name ? { countryName: country.name } : {}),
registrationFlowRequired: true, registrationFlowRequired: false,
createdAt: firebase.firestore.FieldValue.serverTimestamp(), createdAt: firebase.firestore.FieldValue.serverTimestamp(),
updatedAt: firebase.firestore.FieldValue.serverTimestamp(), updatedAt: firebase.firestore.FieldValue.serverTimestamp(),
}, },
{ merge: true } { merge: true }
) )
setTooltip({ text: 'Compte créé, continuons', type: 'success' }) setTooltip({ text: 'Compte créé', type: 'success' })
reset({ index: 0, routes: [{ name: Routes.RegistrationChoice }] }) reset(getMainMenuReset())
} catch (e) { } catch (e) {
console.log('Register error', e?.message) console.log('Register error', e?.message)
const message = const message =
@@ -138,7 +138,7 @@ const CreatePassword = () => {
return ( return (
<Page <Page
width={isWeb ? 600 : null} width={isWeb ? 600 : null}
backgroundImg={isWeb ? background.loginBgWeb : background.homeBG} backgroundImg={background.loginBgWeb}
headerType="NAVIGATION" headerType="NAVIGATION"
title="Inscription" title="Inscription"
> >
+25 -12
View File
@@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { FlatList, Image, Platform, ScrollView, StyleSheet, Text, View } from 'react-native' import { FlatList, Image, Platform, ScrollView, StyleSheet, Text, View } from 'react-native'
import { BlurView } from 'expo-blur' import { BlurView } from 'expo-blur'
import { responsiveHeight } from 'react-native-responsive-dimensions' import { responsiveHeight } from 'react-native-responsive-dimensions'
import { background, icons } from '../../assets' import { background, img } from '../../assets'
import BorderGradientButton from '../../components/BorderGradientButton' import BorderGradientButton from '../../components/BorderGradientButton'
import SearchBar from '../../components/SearchBar' import SearchBar from '../../components/SearchBar'
import ShareBtn from '../../components/ShareBtn/ShareBtn' import ShareBtn from '../../components/ShareBtn/ShareBtn'
@@ -18,7 +18,7 @@ import MobileCoinBadge from '../../components/MobileCoinBadge'
import ResearchHeader from '../Library/components/ResearchHeader' import ResearchHeader from '../Library/components/ResearchHeader'
import SearchResultsList from '../Library/components/SearchResultsList' import SearchResultsList from '../Library/components/SearchResultsList'
import SongCard from './components/SongCard' import SongCard from './components/SongCard'
import ClubCard from '../Home/components/ClubCard' import SpaceLogoCard from '../../components/SpaceLogoCard'
const allowedPremiumLevels = new Set(['starter', 'pro', 'premium']) const allowedPremiumLevels = new Set(['starter', 'pro', 'premium'])
const languageOptions = [ const languageOptions = [
@@ -451,11 +451,25 @@ const HitParade = () => {
}) })
}, [navigateToMusicDetails, songsList]) }, [navigateToMusicDetails, songsList])
const heroTitle = isWeb const heroContent = {
? 'Coups de coeur des utilisateurs' Chansons: {
: `Coups de coeur des utilisateurs catégorie ${selectedCategory}` title: 'Nouveautés Streaming',
subtitle: 'Découvre les morceaux publiés par la communauté MusicLand.',
const heroSubtitle = 'Les 3 musiques les plus écoutées du mois reçoivent des tokens.' },
'Top du mois': {
title: 'Les plus écoutés ce mois-ci',
subtitle: 'Ce classement est basé sur le nombre d’écoutes du mois en cours.',
},
'All Time': {
title: 'Les plus écoutés de tous les temps',
subtitle: 'Ce classement est basé sur le nombre total d’écoutes.',
},
}
const activeHeroContent = heroContent[selectedCategory] || heroContent.Chansons
const heroTitle = isWeb ? 'Streaming : nouveautés et classements' : activeHeroContent.title
const heroSubtitle = isWeb
? 'Découvre les nouveautés, le Top du mois et les titres les plus écoutés de tous les temps.'
: activeHeroContent.subtitle
const renderHero = () => ( const renderHero = () => (
<View style={{ gap: 11, maxWidth: 800, width: '100%', alignSelf: 'center' }}> <View style={{ gap: 11, maxWidth: 800, width: '100%', alignSelf: 'center' }}>
@@ -666,11 +680,10 @@ const HitParade = () => {
</View> </View>
) : null} ) : null}
<View style={{ gap: 12, flex: 1 }}> <View style={{ gap: 12, flex: 1 }}>
{/* <Image <SpaceLogoCard
source={icons.musicLandLogo} source={img.musiclandStreaming}
style={{ alignSelf: 'center', height: 150, resizeMode: 'contain' }} accessibilityLabel="Streaming MusicLand"
/> */} />
<ClubCard />
<StreamingLanguageFilter <StreamingLanguageFilter
selectedLanguage={selectedLanguage} selectedLanguage={selectedLanguage}
onSelectLanguage={setSelectedLanguage} onSelectLanguage={setSelectedLanguage}
+24 -7
View File
@@ -65,9 +65,12 @@ const STAGE_CARD_CONTENT = [
{ {
key: 'director', key: 'director',
step: 'ÉTAPE 3', step: 'ÉTAPE 3',
description: description: {
'Tu vas faire une expérience extraordinaire, tu vas te filmer en train d’interpreter ta chanson en Play Back et je vais te guider pour te faciliter la tâche!', fr: 'Je suis Mr Benaï, responsable de MusicLand Production et je vais t’expliquer toute la finalité de Musicland.',
en: "I'm Mr. Benaï, the head of MusicLand Production, and I'm going to explain to you what MusicLand is all about.",
},
image: cardsImg.video, image: cardsImg.video,
imageStyle: { transform: [{ translateY: 10 }] },
imagePosition: 'left', imagePosition: 'left',
textAlign: 'left', textAlign: 'left',
lockSide: 'right', lockSide: 'right',
@@ -75,8 +78,10 @@ const STAGE_CARD_CONTENT = [
{ {
key: 'publisher', key: 'publisher',
step: 'ÉTAPE 4', step: 'ÉTAPE 4',
description: description: {
'Je suis Mr Benhaï, Producteur de MusicLand, et je vais te faire une proposition qui pourrait t’intéresser. On se retrouve à la sortie du studio !', fr: 'Dans l’espace Vidéo, tu as deux options:\nPlay Back, en direct!\nou génération de ton clip vidéo',
en: 'In the Video section, you have two options:\nPlayback, live!\nor clip vidéo génération',
},
image: cardsImg.production, image: cardsImg.production,
imagePosition: 'right', imagePosition: 'right',
textAlign: 'right', textAlign: 'right',
@@ -101,6 +106,7 @@ const Home = ({ navigation, route }) => {
const { setTooltip } = useMinuit() const { setTooltip } = useMinuit()
const [videoUrl, setVideoUrl] = useState(null) const [videoUrl, setVideoUrl] = useState(null)
const navigateToMusicDetails = useNavigateToMusicDetails() const navigateToMusicDetails = useNavigateToMusicDetails()
const preferredLanguage = currentUserData?.preferredLanguage === 'en' ? 'en' : 'fr'
if (!currentUID) { if (!currentUID) {
return <LandingPage /> return <LandingPage />
@@ -324,7 +330,9 @@ const Home = ({ navigation, route }) => {
const songwriterAction = useMemo(() => getStageAction('songwriter', null), []) const songwriterAction = useMemo(() => getStageAction('songwriter', null), [])
const handleStartNew = useCallback(async () => { const handleStartNew = useCallback(async () => {
const targetRoute = songwriterAction?.route || Routes.CreateLyricsWithAi const targetRoute = isWeb
? songwriterAction?.route || Routes.CreateLyricsWithAi
: Routes.WritingLyrics
const targetParams = songwriterAction?.params const targetParams = songwriterAction?.params
try { try {
@@ -400,9 +408,13 @@ const Home = ({ navigation, route }) => {
() => () =>
STAGE_CARD_CONTENT.map((card) => ({ STAGE_CARD_CONTENT.map((card) => ({
...card, ...card,
description:
typeof card.description === 'string'
? card.description
: card.description[preferredLanguage],
isLocked: stageStatesByKey[card.key]?.isLocked ?? true, isLocked: stageStatesByKey[card.key]?.isLocked ?? true,
})), })),
[stageStatesByKey] [preferredLanguage, stageStatesByKey]
) )
const handleStagePress = useCallback( const handleStagePress = useCallback(
@@ -423,8 +435,12 @@ const Home = ({ navigation, route }) => {
if (!action?.route) { if (!action?.route) {
return return
} }
const targetRoute =
!isWeb && stageKey === 'songwriter' && action.route === Routes.CreateLyricsWithAi
? Routes.WritingLyrics
: action.route
console.log("action route is ", action.route) console.log("action route is ", action.route)
navigate(action.route, action.params) navigate(targetRoute, action.params)
}, },
[currentProject, ensureProjectSelected, hasActiveProject, handleStartNew] [currentProject, ensureProjectSelected, hasActiveProject, handleStartNew]
) )
@@ -448,6 +464,7 @@ const Home = ({ navigation, route }) => {
step={card.step} step={card.step}
description={card.description} description={card.description}
image={card.image} image={card.image}
imageStyle={card.imageStyle}
imagePosition={card.imagePosition} imagePosition={card.imagePosition}
textAlign={card.textAlign} textAlign={card.textAlign}
isLocked={card.isLocked} isLocked={card.isLocked}
+2 -2
View File
@@ -16,7 +16,7 @@ const ClubCard = ({ onPress, containerStyle = null, variant = 'compact' }) => {
<Pressable <Pressable
onPress={onPress} onPress={onPress}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Club MusicLand" accessibilityLabel="Streaming MusicLand"
style={({ pressed }) => [ style={({ pressed }) => [
styles.card, styles.card,
cardBaseStyle, cardBaseStyle,
@@ -24,7 +24,7 @@ const ClubCard = ({ onPress, containerStyle = null, variant = 'compact' }) => {
pressed && styles.cardPressed, pressed && styles.cardPressed,
]} ]}
> >
<Image source={img.musiclandClub} style={imageStyle} /> <Image source={img.musiclandStreaming} style={imageStyle} />
</Pressable> </Pressable>
) )
} }
+5
View File
@@ -10,6 +10,7 @@ const StageCard = ({
step, step,
description, description,
image, image,
imageStyle = null,
imagePosition = 'left', imagePosition = 'left',
textAlign = 'left', textAlign = 'left',
isLocked, isLocked,
@@ -79,6 +80,7 @@ const StageCard = ({
: isImageOnLeft : isImageOnLeft
? { alignSelf: 'flex-end' } ? { alignSelf: 'flex-end' }
: { alignSelf: 'flex-start' }, : { alignSelf: 'flex-start' },
imageStyle,
]} ]}
/> />
</View> </View>
@@ -206,6 +208,9 @@ const StageCard = ({
<Pressable <Pressable
onPress={onPress} onPress={onPress}
disabled={isLocked} disabled={isLocked}
accessibilityRole="button"
accessibilityLabel={`${step} — ${description}`}
accessibilityState={{ disabled: !!isLocked }}
style={({ pressed }) => [ style={({ pressed }) => [
basePressableStyle, basePressableStyle,
containerStyle, containerStyle,
+12 -2
View File
@@ -2,7 +2,9 @@ import AsyncStorage from '@react-native-async-storage/async-storage'
import { useCallback, useMemo, useState } from 'react' import { useCallback, useMemo, useState } from 'react'
import { Image, StyleSheet, Text, TouchableOpacity, View } from 'react-native' import { Image, StyleSheet, Text, TouchableOpacity, View } from 'react-native'
import { background, icons, videos } from '../assets' import { background, icons, videos } from '../assets'
import BackgroundVideo from '../components/BackgroundVideo'
import FullscreenIntroVideo from '../components/FullscreenIntroVideo' import FullscreenIntroVideo from '../components/FullscreenIntroVideo'
import { isWeb } from '../hooks/useLayoutType'
import Page from '../layouts/Page' import Page from '../layouts/Page'
import { Routes } from '../navigation' import { Routes } from '../navigation'
import { navigate, reset } from '../navigation/NavigationService' import { navigate, reset } from '../navigation/NavigationService'
@@ -85,7 +87,15 @@ export default function LandingPage({ route }) {
return ( return (
<> <>
<Page title="Landing Page" backgroundImg={background.homeBGWeb}> <Page
title="Landing Page"
backgroundImg={background.homeBGWeb}
backgroundContent={
phase === FLOW_PHASE.LANGUAGE ? (
<BackgroundVideo source={videos.languageBackground} silent />
) : null
}
>
<View <View
style={{ style={{
flex: 1, flex: 1,
@@ -121,7 +131,7 @@ export default function LandingPage({ route }) {
url={activeVideo} url={activeVideo}
visible visible
onClose={activeVideoClose} onClose={activeVideoClose}
contentFit="contain" contentFit={isWeb || isJingle ? 'contain' : 'cover'}
allowSkip={!isJingle} allowSkip={!isJingle}
requireAudio={!isJingle} requireAudio={!isJingle}
offerSoundActivation={isJingle} offerSoundActivation={isJingle}
+1 -1
View File
@@ -48,7 +48,7 @@ const Library = () => {
hasLikedClips hasLikedClips
return ( return (
<Page backgroundImg={background.libraryBG} headerType="NONE"> <Page backgroundImg={background.libraryMainBackground} headerType="NONE">
{Platform.OS !== 'web' ? ( {Platform.OS !== 'web' ? (
<View <View
style={{ style={{
+1 -1
View File
@@ -111,7 +111,7 @@ const Library = () => {
}, [dropdownVisible, closeDropdown]) }, [dropdownVisible, closeDropdown])
return ( return (
<Page backgroundImg={background.libraryBgWeb} headerType="NONE" width={'80%'}> <Page backgroundImg={background.libraryMainBackground} headerType="NONE" width={'80%'}>
<View style={{ gap: 17, paddingBottom: 20, zIndex: dropdownVisible ? 40 : 1 }}> <View style={{ gap: 17, paddingBottom: 20, zIndex: dropdownVisible ? 40 : 1 }}>
<Image <Image
source={icons.musicLandLogo} source={icons.musicLandLogo}
+29 -21
View File
@@ -1,16 +1,15 @@
import React, { useCallback, useMemo } from 'react' import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { StyleSheet, Text, View } from 'react-native' import { StyleSheet, Text, View } from 'react-native'
import { useIsFocused } from '@react-navigation/native' import { background, videos } from '../../assets'
import { background } from '../../assets'
import BackgroundVideo from '../../components/BackgroundVideo'
import BorderGradientButton from '../../components/BorderGradientButton' import BorderGradientButton from '../../components/BorderGradientButton'
import FullscreenIntroVideo from '../../components/FullscreenIntroVideo'
import GradientButton from '../../components/GradientButton' import GradientButton from '../../components/GradientButton'
import MusicLandHeader from '../../components/MusicLandHeader' import MusicLandHeader from '../../components/MusicLandHeader'
import Page from '../../layouts/Page' import Page from '../../layouts/Page'
import { isWeb } from '../../hooks/useLayoutType' import { isWeb } from '../../hooks/useLayoutType'
import { Routes } from '../../navigation' import { Routes } from '../../navigation'
import { goBack, navigate } from '../../navigation/NavigationService' import { goBack, navigate } from '../../navigation/NavigationService'
import { useUser, useUserData } from '../../providers/UserDataProvider' import { useUserData } from '../../providers/UserDataProvider'
import { gutters, Palette } from '../../styles' import { gutters, Palette } from '../../styles'
import { import {
getPlaybackAiStatusLabel, getPlaybackAiStatusLabel,
@@ -19,11 +18,25 @@ import {
} from '../../utils/playbackAi' } from '../../utils/playbackAi'
const Playback = ({ route, navigation }) => { const Playback = ({ route, navigation }) => {
const isFocused = useIsFocused()
const { project: routeProject, returnToAdventureModal = false } = route.params || {} const { project: routeProject, returnToAdventureModal = false } = route.params || {}
const { videos } = useUser() const { currentUserData, selectedProject } = useUserData() || {}
const { selectedProject } = useUserData() || {} const [videoUrl, setVideoUrl] = useState(null)
const theoUrl = isWeb ? videos?.theoWeb : videos?.theo const preferredLanguage = currentUserData
? currentUserData.preferredLanguage === 'en'
? 'en'
: 'fr'
: null
useEffect(() => {
if (!preferredLanguage) {
return
}
setVideoUrl(
preferredLanguage === 'en' ? videos.videoSpaceIntroEn : videos.videoSpaceIntroFr
)
}, [preferredLanguage])
const project = useMemo(() => { const project = useMemo(() => {
if (routeProject?.id && selectedProject?.id === routeProject.id) { if (routeProject?.id && selectedProject?.id === routeProject.id) {
return selectedProject return selectedProject
@@ -61,14 +74,6 @@ const Playback = ({ route, navigation }) => {
<Page <Page
headerType="NONE" headerType="NONE"
backgroundImg={isWeb ? background.playbackWeb : background.playbackMobile} backgroundImg={isWeb ? background.playbackWeb : background.playbackMobile}
backgroundContent={
isFocused ? (
<BackgroundVideo
source={theoUrl}
soundButtonStyle={isWeb ? styles.rightSideControl : undefined}
/>
) : null
}
containerStyle={isWeb ? styles.videoPage : undefined} containerStyle={isWeb ? styles.videoPage : undefined}
> >
{/* <Image source={ai.theo} style={styles.img} resizeMode="contain" /> */} {/* <Image source={ai.theo} style={styles.img} resizeMode="contain" /> */}
@@ -103,6 +108,13 @@ const Playback = ({ route, navigation }) => {
{/* <BorderGradientButton title="Importer une vidéo" /> */} {/* <BorderGradientButton title="Importer une vidéo" /> */}
</View> </View>
</View> </View>
<FullscreenIntroVideo
url={videoUrl}
visible={!!videoUrl}
onClose={() => setVideoUrl(null)}
contentFit={isWeb ? 'contain' : 'cover'}
skipLabel={preferredLanguage === 'en' ? 'Skip video' : 'Passer la vidéo'}
/>
</Page> </Page>
) )
} }
@@ -114,10 +126,6 @@ const styles = StyleSheet.create({
alignSelf: 'flex-start', alignSelf: 'flex-start',
marginLeft: '4%', marginLeft: '4%',
}, },
rightSideControl: {
right: 24,
left: 'auto',
},
blockedText: { blockedText: {
color: Palette.white, color: Palette.white,
opacity: 0.75, opacity: 0.75,
+5 -3
View File
@@ -1,7 +1,7 @@
import * as ImagePicker from 'expo-image-picker' import * as ImagePicker from 'expo-image-picker'
import useMinuit from 'react-native-minuit/src/hooks/useMinuit.js' import useMinuit from 'react-native-minuit/src/hooks/useMinuit.js'
import React, { useMemo, useState } from 'react' import React, { useMemo, useState } from 'react'
import { Image, Text, View } from 'react-native' import { Image, Platform, Text, View } from 'react-native'
import { background } from '../../assets' import { background } from '../../assets'
import ActivityLoader from '../../components/ActivityLoader' import ActivityLoader from '../../components/ActivityLoader'
import BorderGradientButton from '../../components/BorderGradientButton' import BorderGradientButton from '../../components/BorderGradientButton'
@@ -108,7 +108,7 @@ const PlaybackAi = ({ route }) => {
throw new Error("Impossible d'envoyer la photo vers Firebase") throw new Error("Impossible d'envoyer la photo vers Firebase")
} }
await callStartPlaybackGeneration({ const generationRequest = callStartPlaybackGeneration({
projectId: project.id, projectId: project.id,
photoUrl, photoUrl,
}) })
@@ -121,6 +121,8 @@ const PlaybackAi = ({ route }) => {
playbackPhotoUrl: photoUrl, playbackPhotoUrl: photoUrl,
}, },
}) })
await generationRequest
} catch (error) { } catch (error) {
setTooltip({ setTooltip({
type: 'error', type: 'error',
@@ -133,7 +135,7 @@ const PlaybackAi = ({ route }) => {
return ( return (
<Page <Page
backgroundImg={background.playbackBG2} backgroundImg={Platform.OS === 'web' ? background.playbackWeb : background.playbackMobile}
backgroundColor="#07111B" backgroundColor="#07111B"
headerType="NONE" headerType="NONE"
> >
+1 -1
View File
@@ -96,7 +96,7 @@ const PlaybackAiStatus = ({ route }) => {
return ( return (
<Page <Page
backgroundImg={background.playbackBG2} backgroundImg={background.playbackMobile}
backgroundColor="#07111B" backgroundColor="#07111B"
headerType="NONE" headerType="NONE"
> >
+1 -1
View File
@@ -91,7 +91,7 @@ const PlaybackAiStatus = ({ route }) => {
return ( return (
<Page <Page
backgroundImg={background.playbackBG2} backgroundImg={background.playbackWeb}
backgroundColor="#07111B" backgroundColor="#07111B"
headerType="NONE" headerType="NONE"
> >
+40 -26
View File
@@ -27,14 +27,13 @@ const PlaybackGuide = ({ navigation }) => {
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
> >
<Text style={styles.title}> <Text style={styles.title}>
Le Guide Ultime du Playback de TA Chanson : L'Expérience Créative Totale Le Guide Ultime du Playback L'Expérience Créative Totale
</Text> </Text>
<Text style={styles.sectionTitle}> <Text style={styles.sectionTitle}>Introduction</Text>
Introduction : Tu Es l'Artiste ET le Performeur ! <Text style={styles.sectionTitle}>Tu es l'artiste et l’interprête!</Text>
</Text>
<Text style={styles.text}> <Text style={styles.text}>
Imagine ça : tu as créé Ta chanson, TES paroles, TA mélodie, TON univers. Et Imagine ça : tu as créé TA chanson, TES paroles, TA mélodie, TON univers. Et
maintenant, tu vas lui donner vie en playback. C'est une expérience UNIQUE au monde ! maintenant, tu vas lui donner vie en playback. C'est une expérience UNIQUE au monde !
Tu n'imites personne - tu deviens la version scénique de toi-même. C'est comme avoir Tu n'imites personne - tu deviens la version scénique de toi-même. C'est comme avoir
un jumeau qui chante pendant que toi, tu performes. Double dose de créativité, double un jumeau qui chante pendant que toi, tu performes. Double dose de créativité, double
@@ -53,9 +52,9 @@ const PlaybackGuide = ({ navigation }) => {
transparaître dans chaque mouvement de tes lèvres. transparaître dans chaque mouvement de tes lèvres.
</Text> </Text>
<Text style={styles.subTitle}>La Règle des 15 Écoutes - Version Créateur</Text> <Text style={styles.subTitle}>La Règle des 12 Écoutes - Version Créateur</Text>
<Text style={styles.text}> <Text style={styles.text}>
Oui, même pour TA chanson, les 15 écoutes sont cruciales ! Mais avec une twist magique Oui, même pour TA chanson, les 12 écoutes sont cruciales ! Mais avec une twist magique
: :
</Text> </Text>
<Text style={styles.text}> <Text style={styles.text}>
@@ -69,12 +68,12 @@ const PlaybackGuide = ({ navigation }) => {
sur-articulés ?{'\n'}• Où sont tes respirations naturelles dans l'enregistrement ? sur-articulés ?{'\n'}• Où sont tes respirations naturelles dans l'enregistrement ?
</Text> </Text>
<Text style={styles.text}> <Text style={styles.text}>
<Text style={styles.bold}>Écoutes 11-15 : La Transformation</Text> Maintenant, <Text style={styles.bold}>Écoutes 11-12 : La Transformation</Text> Maintenant,
détache-toi du "toi qui a enregistré" pour devenir le "toi qui performe". C'est là que détache-toi du "toi qui a enregistré" pour devenir le "toi qui interprête". C'est là
la magie opère ! que la magie opère !
</Text> </Text>
<Text style={styles.subTitle}>Les Secrets du Créateur-Performeur</Text> <Text style={styles.subTitle}>Les Secrets du PLAYBACKER</Text>
<Text style={styles.text}> <Text style={styles.text}>
<Text style={styles.bold}>Le Phénomène de Dédoublement</Text> C'est normal de se <Text style={styles.bold}>Le Phénomène de Dédoublement</Text> C'est normal de se
sentir bizarre au début - tu entends ta voix mais tu ne chantes pas ! Embrasse cette sentir bizarre au début - tu entends ta voix mais tu ne chantes pas ! Embrasse cette
@@ -107,8 +106,9 @@ const PlaybackGuide = ({ navigation }) => {
<Text style={styles.subTitle}>Les Angles Qui Racontent Ton Histoire</Text> <Text style={styles.subTitle}>Les Angles Qui Racontent Ton Histoire</Text>
<Text style={styles.text}> <Text style={styles.text}>
<Text style={styles.bold}>L'Angle "Confession" (Très Proche)</Text> Pour les parties <Text style={styles.bold}>L'Angle "Confession" (Très Proche)</Text> Pour une
intimes de ta chanson. Le spectateur doit sentir que tu lui confies un secret. interprêtation intimes de ta chanson. Le spectateur doit sentir que tu lui confies un
secret.
</Text> </Text>
<Text style={styles.text}> <Text style={styles.text}>
<Text style={styles.bold}>L'Angle "Concert" (Plan Large)</Text> Montre l'artiste <Text style={styles.bold}>L'Angle "Concert" (Plan Large)</Text> Montre l'artiste
@@ -125,9 +125,10 @@ const PlaybackGuide = ({ navigation }) => {
</Text> </Text>
<Text style={styles.text}>C'est encore plus puissant quand c'est TA chanson !</Text> <Text style={styles.text}>C'est encore plus puissant quand c'est TA chanson !</Text>
<Text style={styles.text}> <Text style={styles.text}>
Si tu as composé avec un instrument :{'\n'}• Mime-le même si tu ne l'as pas sous la Si tu as composé avec un instrument :{'\n'}• Trouve l’instrument de ta chanson et mime
main comme si tu savait en jouer.
{'\n'}• Montre les accords que TU as choisis {'\n'}• Si tu n’en as pas, “fais comme si”, fait semblant, montre les accords que tu
as choisis
{'\n'}• Tes mains connaissent les mouvements par cœur {'\n'}• Tes mains connaissent les mouvements par cœur
</Text> </Text>
<Text style={styles.text}> <Text style={styles.text}>
@@ -152,7 +153,7 @@ const PlaybackGuide = ({ navigation }) => {
<Text style={styles.subTitle}>Les Scènes Qui Amplifient Ta Création</Text> <Text style={styles.subTitle}>Les Scènes Qui Amplifient Ta Création</Text>
<Text style={styles.text}> <Text style={styles.text}>
<Text style={styles.bold}>L'Endroit Où Tu l'as Écrite - Le Retour aux Sources</Text>{' '} <Text style={styles.bold}>L'Endroit Où Tu l'as Écrite - Le Retour aux Sources</Text>{' '}
Si tu as écrit ta chanson dans ta chambre, filme-là ! C'est puissant de montrer Si tu as écrit ta chanson dans ta chambre, filme là ! C'est puissant de montrer
l'artiste là où la magie a commencé. L'authenticité sera palpable. l'artiste là où la magie a commencé. L'authenticité sera palpable.
</Text> </Text>
<Text style={styles.text}> <Text style={styles.text}>
@@ -174,10 +175,12 @@ const PlaybackGuide = ({ navigation }) => {
<Text style={styles.subTitle}>Suggestions Créatives Uniques :</Text> <Text style={styles.subTitle}>Suggestions Créatives Uniques :</Text>
<Text style={styles.text}> <Text style={styles.text}>
• La Voiture : Si c'est là que tu as eu l'idée de la mélodie • La Voiture : Si c'est là que tu as eu l'idée de la mélodie
{'\n'}• La Douche : Avec le téléphone protégé, si c'est là que tu chantes toujours
{'\n'}• Le Toit/Balcon : Pour une sensation de "crier au monde" ta création {'\n'}• Le Toit/Balcon : Pour une sensation de "crier au monde" ta création
{'\n'}• Devant le Miroir : Le dialogue entre toi créateur et toi performeur {'\n'}• Devant le Miroir : Le dialogue entre toi créateur et toi performeur
{'\n'}• Dans le Noir avec une Seule Lumière : Pour l'aspect dramatique et intime {'\n'}• Dans le Noir avec une Seule Lumière : Pour l'aspect dramatique et intime
{'\n'}• Dans un endroit insolite: une station service, sur un pont, sur une
balançoire, dans un pré, dans une forêt, dans une piscine,... ta seule limite… c’est
ton imagination!
</Text> </Text>
<Text style={styles.chapterTitle}>Chapitre 4 : Le Mindset du Créateur-Performeur</Text> <Text style={styles.chapterTitle}>Chapitre 4 : Le Mindset du Créateur-Performeur</Text>
@@ -226,7 +229,7 @@ const PlaybackGuide = ({ navigation }) => {
:{'\n'}• Les gens découvrent ta musique :{'\n'}• Les gens découvrent ta musique
{'\n'}• Ils voient ta personnalité {'\n'}• Ils voient ta personnalité
{'\n'}• Ils connectent avec l'artiste complet que tu es {'\n'}• Ils connectent avec l'artiste complet que tu es
{'\n'}• Ils witness la naissance visuelle de ton art {'\n'}• Ils participe à la naissance visuelle de ton art
</Text> </Text>
<Text style={styles.chapterTitle}>Résumé : Ton Parcours Créatif Unique</Text> <Text style={styles.chapterTitle}>Résumé : Ton Parcours Créatif Unique</Text>
@@ -266,8 +269,10 @@ const PlaybackGuide = ({ navigation }) => {
<Text style={styles.chapterTitle}>Ton Défi Ultime : La Première Mondiale !</Text> <Text style={styles.chapterTitle}>Ton Défi Ultime : La Première Mondiale !</Text>
<Text style={styles.text}> <Text style={styles.text}>
Tu es sur le point de faire quelque chose qu'AUCUN autre artiste ne peut faire : créer Tu es sur le point de faire quelque chose qu'AUCUN autre artiste ne peut faire : créer
la performance visuelle parfaite de TA chanson. C'est ta première mondiale, ton moment la performance visuelle de TA chanson. C'est ta première mondiale, ton moment unique !
unique ! </Text>
<Text style={[styles.text, styles.bold]}>
Elle ne doit pas être parfaite, elle doit être authentique!
</Text> </Text>
<Text style={styles.text}> <Text style={styles.text}>
Dans les 24 prochaines heures, donne vie visuellement à ta création. Choisis ta Dans les 24 prochaines heures, donne vie visuellement à ta création. Choisis ta
@@ -283,12 +288,12 @@ const PlaybackGuide = ({ navigation }) => {
C'est ton moment. Ta chanson. Ta performance. Ton histoire. C'est ton moment. Ta chanson. Ta performance. Ton histoire.
</Text> </Text>
<Text style={styles.text}> <Text style={styles.text}>
Le monde attend de découvrir l'artiste complet que tu es ! 🎤🎵✨ Le monde attend de découvrir l'artiste qui est en toi ! 🎤🎵✨
</Text> </Text>
<Text style={styles.text}> <Text style={[styles.text, styles.bold]}>
#MonPlaybackMaChanson #CréateurPerformeur #DoubleArtiste #MonPlaybackMaChanson #Playbacker-performer #Artisteletemps d’une chanson
</Text> </Text>
<Text style={styles.text}> <Text style={[styles.text, styles.italic]}>
P.S. : Garde chaque prise, même les "ratées". Dans 10 ans, tu regarderas ces vidéos et P.S. : Garde chaque prise, même les "ratées". Dans 10 ans, tu regarderas ces vidéos et
tu verras le début de ton parcours d'artiste. C'est de l'or pur ! tu verras le début de ton parcours d'artiste. C'est de l'or pur !
</Text> </Text>
@@ -302,7 +307,9 @@ const PlaybackGuide = ({ navigation }) => {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
marginHorizontal: Platform.OS === 'web' ? gutters : 0, width: '100%',
maxWidth: 900,
alignSelf: 'center',
marginBottom: gutters, marginBottom: gutters,
borderRadius: 20, borderRadius: 20,
overflow: 'hidden', overflow: 'hidden',
@@ -314,6 +321,7 @@ const styles = StyleSheet.create({
scrollContent: { scrollContent: {
padding: gutters, padding: gutters,
paddingBottom: gutters * 3, paddingBottom: gutters * 3,
alignItems: 'center',
}, },
title: { title: {
fontFamily: FONT_FAMILY.InterBold, fontFamily: FONT_FAMILY.InterBold,
@@ -328,6 +336,7 @@ const styles = StyleSheet.create({
color: Palette.primary, color: Palette.primary,
marginBottom: 10, marginBottom: 10,
marginTop: 10, marginTop: 10,
textAlign: 'center',
}, },
chapterTitle: { chapterTitle: {
fontFamily: FONT_FAMILY.InterBold, fontFamily: FONT_FAMILY.InterBold,
@@ -343,6 +352,7 @@ const styles = StyleSheet.create({
color: Palette.white, color: Palette.white,
marginBottom: 8, marginBottom: 8,
marginTop: 15, marginTop: 15,
textAlign: 'center',
}, },
text: { text: {
fontFamily: FONT_FAMILY.InterRegular, fontFamily: FONT_FAMILY.InterRegular,
@@ -350,10 +360,14 @@ const styles = StyleSheet.create({
color: Palette.white, color: Palette.white,
marginBottom: 10, marginBottom: 10,
lineHeight: 24, lineHeight: 24,
textAlign: 'center',
}, },
bold: { bold: {
fontFamily: FONT_FAMILY.InterBold, fontFamily: FONT_FAMILY.InterBold,
}, },
italic: {
fontStyle: 'italic',
},
}) })
export default PlaybackGuide export default PlaybackGuide
+124 -9
View File
@@ -1,9 +1,16 @@
import { useFocusEffect } from '@react-navigation/native' import { useFocusEffect, useIsFocused } from '@react-navigation/native'
import { CameraView, useCameraPermissions } from 'expo-camera' import { CameraView, useCameraPermissions } from 'expo-camera'
import { activateKeepAwakeAsync, deactivateKeepAwake } from 'expo-keep-awake' import { activateKeepAwakeAsync, deactivateKeepAwake } from 'expo-keep-awake'
import * as FileSystem from 'expo-file-system' import * as FileSystem from 'expo-file-system'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { BackHandler, Text, TouchableOpacity, View } from 'react-native' import {
ActivityIndicator,
BackHandler,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context' import { useSafeAreaInsets } from 'react-native-safe-area-context'
import Svg, { Circle } from 'react-native-svg' import Svg, { Circle } from 'react-native-svg'
import alert from '../../components/Alert' import alert from '../../components/Alert'
@@ -25,6 +32,7 @@ import CreateLyricsHeader from '../Writing/components/CreateLyricsHeader'
const TIME_BEFORE_INCREMENT_MS = 20000 // 20s const TIME_BEFORE_INCREMENT_MS = 20000 // 20s
const COUNTDOWN_SECONDS = 10 const COUNTDOWN_SECONDS = 10
const CAMERA_STARTUP_DELAY_MS = 250 const CAMERA_STARTUP_DELAY_MS = 250
const CAMERA_READY_TIMEOUT_MS = 8000
const LOG_PREFIX = '[RecordPlayback]' const LOG_PREFIX = '[RecordPlayback]'
const KEEP_AWAKE_TAG = 'record-playback' const KEEP_AWAKE_TAG = 'record-playback'
const log = const log =
@@ -39,6 +47,7 @@ const CAMERA_FACING_OPTIONS = [
const RecordPlayback = ({ route }) => { const RecordPlayback = ({ route }) => {
const { top, bottom } = useSafeAreaInsets() const { top, bottom } = useSafeAreaInsets()
const isFocused = useIsFocused()
const { project } = route.params || {} const { project } = route.params || {}
log('Route params received', { hasProject: !!project }) log('Route params received', { hasProject: !!project })
const projectId = project?.id const projectId = project?.id
@@ -76,6 +85,9 @@ const RecordPlayback = ({ route }) => {
const [isRecording, setIsRecording] = useState(false) const [isRecording, setIsRecording] = useState(false)
const [showProgress, setShowProgress] = useState(false) const [showProgress, setShowProgress] = useState(false)
const [cameraFacing, setCameraFacing] = useState('front') const [cameraFacing, setCameraFacing] = useState('front')
const [cameraReady, setCameraReady] = useState(false)
const [cameraMountError, setCameraMountError] = useState(null)
const [cameraMountKey, setCameraMountKey] = useState(0)
// Musique // Musique
const songUrl = project?.songUrl || null const songUrl = project?.songUrl || null
@@ -771,14 +783,68 @@ const RecordPlayback = ({ route }) => {
} }
const permissionsGranted = !!cameraPermission?.granted const permissionsGranted = !!cameraPermission?.granted
const cameraCanMount = permissionsGranted && isFocused
useEffect(() => { useEffect(() => {
log('Camera permission state updated', { granted: permissionsGranted }) log('Camera permission state updated', { granted: permissionsGranted })
}, [permissionsGranted]) }, [permissionsGranted])
useEffect(() => {
setCameraReady(false)
setCameraMountError(null)
}, [cameraCanMount, cameraFacing])
useEffect(() => {
if (!cameraCanMount || cameraReady || cameraMountError) return undefined
const timeout = setTimeout(() => {
setCameraMountError("La caméra n'a pas répondu à temps.")
}, CAMERA_READY_TIMEOUT_MS)
return () => clearTimeout(timeout)
}, [cameraCanMount, cameraFacing, cameraMountError, cameraMountKey, cameraReady])
const handleCameraFacingChange = useCallback((nextFacing) => {
setCameraReady(false)
setCameraMountError(null)
setCameraFacing(nextFacing)
}, [])
const handleCameraReady = useCallback(() => {
log('Camera preview ready', { facing: cameraFacing })
setCameraMountError(null)
setCameraReady(true)
}, [cameraFacing])
const handleCameraMountError = useCallback((event) => {
const message = event?.message || "La caméra n'a pas pu démarrer."
console.error(LOG_PREFIX, 'Camera mount error', message)
setCameraReady(false)
setCameraMountError(message)
}, [])
const retryCameraMount = useCallback(() => {
setCameraReady(false)
setCameraMountError(null)
setCameraMountKey((currentKey) => currentKey + 1)
}, [])
return ( return (
<View style={{ flex: 1 }}> <View style={{ flex: 1, backgroundColor: Palette.black }}>
<CameraView ref={cameraRef} style={{ flex: 1 }} facing={cameraFacing} mode="video" mute> {cameraCanMount && (
<CameraView
key={`${cameraFacing}-${cameraMountKey}`}
ref={cameraRef}
style={StyleSheet.absoluteFillObject}
facing={cameraFacing}
mode="video"
mute
active={isFocused}
onCameraReady={handleCameraReady}
onMountError={handleCameraMountError}
/>
)}
<View <View
style={{ style={{
paddingHorizontal: gutters, paddingHorizontal: gutters,
@@ -792,8 +858,8 @@ const RecordPlayback = ({ route }) => {
<View style={{ marginTop: 12, alignItems: 'flex-end' }}> <View style={{ marginTop: 12, alignItems: 'flex-end' }}>
<CameraFacingSelector <CameraFacingSelector
value={cameraFacing} value={cameraFacing}
onChange={setCameraFacing} onChange={handleCameraFacingChange}
disabled={!permissionsGranted || isPreparing || isRecording} disabled={!cameraReady || isPreparing || isRecording}
/> />
</View> </View>
@@ -844,10 +910,60 @@ const RecordPlayback = ({ route }) => {
/> />
</View> </View>
)} )}
{cameraCanMount && !cameraReady && !cameraMountError && (
<View
style={{
...StyleSheet.absoluteFillObject,
alignItems: 'center',
justifyContent: 'center',
gap: 12,
}}
>
<ActivityIndicator size="large" color={Palette.white} />
<Text
style={{
color: Palette.white,
fontFamily: FONT_FAMILY.InterMedium,
fontSize: 15,
}}
>
Initialisation de la caméra…
</Text>
</View>
)}
{cameraMountError && (
<View
style={{
...StyleSheet.absoluteFillObject,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: gutters,
gap: 16,
}}
>
<Text
style={{
color: Palette.white,
fontFamily: FONT_FAMILY.InterMedium,
fontSize: 16,
textAlign: 'center',
}}
>
Impossible d'afficher la caméra. Réessaie dans un instant.
</Text>
<GradientButton
title="Réessayer la caméra"
containerStyle={{ width: '80%' }}
onPress={retryCameraMount}
/>
</View>
)}
</View> </View>
{/* Start button */} {/* Start button */}
{permissionsGranted && !isPreparing && !isRecording && ( {cameraReady && !isPreparing && !isRecording && (
<GradientButton <GradientButton
title="Lancer ma musique" title="Lancer ma musique"
containerStyle={{ width: '80%', alignSelf: 'center' }} containerStyle={{ width: '80%', alignSelf: 'center' }}
@@ -857,7 +973,7 @@ const RecordPlayback = ({ route }) => {
)} )}
{/* Progress circulaire + restart */} {/* Progress circulaire + restart */}
{permissionsGranted && (isRecording || showProgress || isPreparing) && ( {cameraReady && (isRecording || showProgress || isPreparing) && (
<View <View
style={{ style={{
position: 'absolute', position: 'absolute',
@@ -906,7 +1022,6 @@ const RecordPlayback = ({ route }) => {
</View> </View>
)} )}
</View> </View>
</CameraView>
{/* CreateLyricsHeader overlay outside of CameraView */} {/* CreateLyricsHeader overlay outside of CameraView */}
<View <View
pointerEvents="box-none" pointerEvents="box-none"
+88 -11
View File
@@ -8,8 +8,10 @@ import useDataFromRef from '../../hooks/useDataFromRef'
import usePlayer from '../../hooks/usePlayer' import usePlayer from '../../hooks/usePlayer'
import PlaybackItem from './components/PlaybackItem' import PlaybackItem from './components/PlaybackItem'
import PlaybackCharts from './components/PlaybackCharts' import PlaybackCharts from './components/PlaybackCharts'
import PlaybackTypeTabs from './components/PlaybackTypeTabs'
import { Palette } from '../../styles' import { Palette } from '../../styles'
import { FONT_FAMILY } from '../../styles/Fonts' import { FONT_FAMILY } from '../../styles/Fonts'
import { getPlaybackType, matchesPlaybackType, PLAYBACK_TYPE } from '../../utils/playbackType'
const Playbacks = () => { const Playbacks = () => {
const [activeIndex, setActiveIndex] = useState(0) const [activeIndex, setActiveIndex] = useState(0)
@@ -21,25 +23,36 @@ const Playbacks = () => {
const initialFocusProjectId = route?.params?.projectId || route?.params?.focusId || null const initialFocusProjectId = route?.params?.projectId || route?.params?.focusId || null
const [focusProjectId, setFocusProjectId] = useState(initialFocusProjectId) const [focusProjectId, setFocusProjectId] = useState(initialFocusProjectId)
const [viewMode, setViewMode] = useState(initialFocusProjectId ? 'feed' : 'charts') const [viewMode, setViewMode] = useState(initialFocusProjectId ? 'feed' : 'charts')
const { data: rawPlaybacks = [], loadMore } = useDataFromRef({ const [playbackType, setPlaybackType] = useState(PLAYBACK_TYPE.LIVE)
const {
data: rawPlaybacks = [],
loadMore,
hasMore,
loading,
} = useDataFromRef({
ref: projectsRef.where('playbackUrl', '!=', null), ref: projectsRef.where('playbackUrl', '!=', null),
simpleRef: false, simpleRef: false,
listener: false, listener: false,
usePagination: true, usePagination: true,
batchSize: 6, batchSize: 6,
}) })
const filteredPlaybacks = useMemo(
() => rawPlaybacks.filter((item) => matchesPlaybackType(item, playbackType)),
[playbackType, rawPlaybacks]
)
const focusIndex = useMemo(() => { const focusIndex = useMemo(() => {
if (!focusProjectId) return -1 if (!focusProjectId) return -1
return rawPlaybacks.findIndex((p) => p?.id === focusProjectId) return filteredPlaybacks.findIndex((p) => p?.id === focusProjectId)
}, [focusProjectId, rawPlaybacks]) }, [filteredPlaybacks, focusProjectId])
const playbacks = useMemo(() => { const playbacks = useMemo(() => {
if (focusIndex < 0) return rawPlaybacks if (focusIndex < 0) return filteredPlaybacks
const target = rawPlaybacks[focusIndex] const target = filteredPlaybacks[focusIndex]
const before = rawPlaybacks.slice(0, focusIndex) const before = filteredPlaybacks.slice(0, focusIndex)
const after = rawPlaybacks.slice(focusIndex + 1) const after = filteredPlaybacks.slice(focusIndex + 1)
return [target, ...after, ...before] return [target, ...after, ...before]
}, [focusIndex, rawPlaybacks]) }, [filteredPlaybacks, focusIndex])
const onSnap = useCallback( const onSnap = useCallback(
(index) => { (index) => {
@@ -63,6 +76,14 @@ const Playbacks = () => {
} }
}, [focusProjectId]) }, [focusProjectId])
useEffect(() => {
if (!focusProjectId) return
const focusedPlayback = rawPlaybacks.find((item) => item?.id === focusProjectId)
if (focusedPlayback) {
setPlaybackType(getPlaybackType(focusedPlayback))
}
}, [focusProjectId, rawPlaybacks])
useEffect(() => { useEffect(() => {
if (loadMore && triedLoadMoreRef.current < 6) { if (loadMore && triedLoadMoreRef.current < 6) {
triedLoadMoreRef.current += 1 triedLoadMoreRef.current += 1
@@ -70,6 +91,12 @@ const Playbacks = () => {
} }
}, [loadMore, playbacks]) }, [loadMore, playbacks])
useEffect(() => {
if (playbacks.length === 0 && hasMore && !loading) {
loadMore?.()
}
}, [hasMore, loadMore, loading, playbacks.length])
useEffect(() => { useEffect(() => {
if (!focusProjectId) return if (!focusProjectId) return
if (focusIndex >= 0 && !hasAppliedFocusRef.current) { if (focusIndex >= 0 && !hasAppliedFocusRef.current) {
@@ -107,6 +134,12 @@ const Playbacks = () => {
const showFeed = viewMode === 'feed' const showFeed = viewMode === 'feed'
const handlePlaybackTypeChange = useCallback((nextType) => {
setFocusProjectId(null)
setPlaybackType(nextType)
setActiveIndex(0)
}, [])
const openFeedForPlayback = useCallback((project) => { const openFeedForPlayback = useCallback((project) => {
const nextId = const nextId =
project && typeof project === 'object' project && typeof project === 'object'
@@ -115,17 +148,27 @@ const Playbacks = () => {
if (!nextId) { if (!nextId) {
return return
} }
if (project && typeof project === 'object') {
setPlaybackType(getPlaybackType(project))
}
setFocusProjectId(nextId) setFocusProjectId(nextId)
setViewMode('feed') setViewMode('feed')
}, []) }, [])
if (!showFeed) { if (!showFeed) {
return <PlaybackCharts onPlaybackPress={openFeedForPlayback} /> return (
<PlaybackCharts
onPlaybackPress={openFeedForPlayback}
playbackType={playbackType}
onPlaybackTypeChange={handlePlaybackTypeChange}
/>
)
} }
return ( return (
<View style={{ flex: 1, backgroundColor: 'black' }}> <View style={{ flex: 1, backgroundColor: 'black' }}>
<Carousel <Carousel
key={playbackType}
data={playbacks} data={playbacks}
vertical vertical
height={responsiveHeight(100)} height={responsiveHeight(100)}
@@ -141,6 +184,19 @@ const Playbacks = () => {
/> />
)} )}
/> />
{playbacks.length === 0 && !loading ? (
<View style={styles.emptyState} pointerEvents="none">
<Text style={styles.emptyStateText}>
Aucun {playbackType === PLAYBACK_TYPE.AI ? 'playback IA' : 'playback live'} pour le moment.
</Text>
</View>
) : null}
<View style={styles.feedHeader}>
<PlaybackTypeTabs
value={playbackType}
onChange={handlePlaybackTypeChange}
compact
/>
<Pressable <Pressable
style={styles.backToCharts} style={styles.backToCharts}
hitSlop={12} hitSlop={12}
@@ -150,16 +206,25 @@ const Playbacks = () => {
<Text style={styles.backToChartsText}>Classement</Text> <Text style={styles.backToChartsText}>Classement</Text>
</Pressable> </Pressable>
</View> </View>
</View>
) )
} }
export default Playbacks export default Playbacks
const styles = StyleSheet.create({ const styles = StyleSheet.create({
backToCharts: { feedHeader: {
position: 'absolute', position: 'absolute',
top: 32, top: 32,
right: 20, left: 16,
right: 16,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: 8,
zIndex: 20,
},
backToCharts: {
paddingVertical: 8, paddingVertical: 8,
paddingHorizontal: 12, paddingHorizontal: 12,
borderRadius: 12, borderRadius: 12,
@@ -171,4 +236,16 @@ const styles = StyleSheet.create({
fontSize: 12, fontSize: 12,
fontFamily: FONT_FAMILY.InterSemiBold, fontFamily: FONT_FAMILY.InterSemiBold,
}, },
emptyState: {
...StyleSheet.absoluteFillObject,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 24,
},
emptyStateText: {
color: Palette.white,
fontSize: 15,
fontFamily: FONT_FAMILY.InterMedium,
textAlign: 'center',
},
}) })
+84 -11
View File
@@ -18,6 +18,8 @@ import { Palette } from '../../styles'
import { FONT_FAMILY } from '../../styles/Fonts' import { FONT_FAMILY } from '../../styles/Fonts'
import PlaybackItem from './components/PlaybackItem.web' import PlaybackItem from './components/PlaybackItem.web'
import PlaybackCharts from './components/PlaybackCharts' import PlaybackCharts from './components/PlaybackCharts'
import PlaybackTypeTabs from './components/PlaybackTypeTabs'
import { getPlaybackType, matchesPlaybackType, PLAYBACK_TYPE } from '../../utils/playbackType'
const Playbacks = () => { const Playbacks = () => {
const { height: windowHeight } = useWindowDimensions() const { height: windowHeight } = useWindowDimensions()
@@ -28,6 +30,7 @@ const Playbacks = () => {
const initialFocusProjectId = route?.params?.projectId || route?.params?.focusId || null const initialFocusProjectId = route?.params?.projectId || route?.params?.focusId || null
const [focusProjectId, setFocusProjectId] = useState(initialFocusProjectId) const [focusProjectId, setFocusProjectId] = useState(initialFocusProjectId)
const [viewMode, setViewMode] = useState(initialFocusProjectId ? 'feed' : 'charts') const [viewMode, setViewMode] = useState(initialFocusProjectId ? 'feed' : 'charts')
const [playbackType, setPlaybackType] = useState(PLAYBACK_TYPE.LIVE)
const userCache = useRef(new Map()) const userCache = useRef(new Map())
const { getUserByUid } = useUser() || {} const { getUserByUid } = useUser() || {}
const isFocused = useIsFocused() const isFocused = useIsFocused()
@@ -43,18 +46,23 @@ const Playbacks = () => {
usePagination: true, usePagination: true,
batchSize: 6, batchSize: 6,
}) })
const filteredPlaybacks = useMemo(
() => rawPlaybacks.filter((item) => matchesPlaybackType(item, playbackType)),
[playbackType, rawPlaybacks]
)
const focusIndex = useMemo(() => { const focusIndex = useMemo(() => {
if (!focusProjectId) return -1 if (!focusProjectId) return -1
return rawPlaybacks.findIndex((p) => p?.id === focusProjectId) return filteredPlaybacks.findIndex((p) => p?.id === focusProjectId)
}, [focusProjectId, rawPlaybacks]) }, [filteredPlaybacks, focusProjectId])
const playbacks = useMemo(() => { const playbacks = useMemo(() => {
if (focusIndex < 0) return rawPlaybacks if (focusIndex < 0) return filteredPlaybacks
const target = rawPlaybacks[focusIndex] const target = filteredPlaybacks[focusIndex]
const before = rawPlaybacks.slice(0, focusIndex) const before = filteredPlaybacks.slice(0, focusIndex)
const after = rawPlaybacks.slice(focusIndex + 1) const after = filteredPlaybacks.slice(focusIndex + 1)
return [target, ...after, ...before] return [target, ...after, ...before]
}, [focusIndex, rawPlaybacks]) }, [filteredPlaybacks, focusIndex])
const activePlayback = playbacks?.[activeIndex] || null const activePlayback = playbacks?.[activeIndex] || null
const activeVideoUrl = activePlayback?.playbackUrl || null const activeVideoUrl = activePlayback?.playbackUrl || null
@@ -187,6 +195,14 @@ const Playbacks = () => {
focusLoadAttemptsRef.current = 0 focusLoadAttemptsRef.current = 0
}, [focusProjectId]) }, [focusProjectId])
useEffect(() => {
if (!focusProjectId) return
const focusedPlayback = rawPlaybacks.find((item) => item?.id === focusProjectId)
if (focusedPlayback) {
setPlaybackType(getPlaybackType(focusedPlayback))
}
}, [focusProjectId, rawPlaybacks])
useEffect(() => { useEffect(() => {
if (!focusProjectId) return if (!focusProjectId) return
if (focusIndex >= 0 && !hasAppliedFocusRef.current) { if (focusIndex >= 0 && !hasAppliedFocusRef.current) {
@@ -206,6 +222,12 @@ const Playbacks = () => {
} }
}, [focusIndex, focusProjectId, loadMore, playbacks]) }, [focusIndex, focusProjectId, loadMore, playbacks])
useEffect(() => {
if (playbacks.length === 0 && hasMore && !loading) {
loadMore?.()
}
}, [hasMore, loadMore, loading, playbacks.length])
useEffect(() => { useEffect(() => {
const nextFocusId = route?.params?.projectId || route?.params?.focusId || null const nextFocusId = route?.params?.projectId || route?.params?.focusId || null
setFocusProjectId(nextFocusId) setFocusProjectId(nextFocusId)
@@ -334,6 +356,13 @@ const Playbacks = () => {
const showFeed = viewMode === 'feed' const showFeed = viewMode === 'feed'
const handlePlaybackTypeChange = useCallback((nextType) => {
setFocusProjectId(null)
setPlaybackType(nextType)
lastActiveIndexRef.current = 0
setActiveIndex(0)
}, [])
const openFeedForPlayback = useCallback((project) => { const openFeedForPlayback = useCallback((project) => {
const nextId = const nextId =
project && typeof project === 'object' project && typeof project === 'object'
@@ -342,12 +371,21 @@ const Playbacks = () => {
if (!nextId) { if (!nextId) {
return return
} }
if (project && typeof project === 'object') {
setPlaybackType(getPlaybackType(project))
}
setFocusProjectId(nextId) setFocusProjectId(nextId)
setViewMode('feed') setViewMode('feed')
}, []) }, [])
if (!showFeed) { if (!showFeed) {
return <PlaybackCharts onPlaybackPress={openFeedForPlayback} /> return (
<PlaybackCharts
onPlaybackPress={openFeedForPlayback}
playbackType={playbackType}
onPlaybackTypeChange={handlePlaybackTypeChange}
/>
)
} }
return ( return (
@@ -377,6 +415,7 @@ const Playbacks = () => {
</View> </View>
) : null} ) : null}
<FlatList <FlatList
key={playbackType}
ref={listRef} ref={listRef}
data={playbacks} data={playbacks}
keyExtractor={(item, index) => String(item?.id || index)} keyExtractor={(item, index) => String(item?.id || index)}
@@ -401,6 +440,13 @@ const Playbacks = () => {
onScroll={onScroll} onScroll={onScroll}
scrollEventThrottle={16} scrollEventThrottle={16}
/> />
{playbacks.length === 0 && !loading ? (
<View style={styles.emptyState} pointerEvents="none">
<Text style={styles.emptyStateText}>
Aucun {playbackType === PLAYBACK_TYPE.AI ? 'playback IA' : 'playback live'} pour le moment.
</Text>
</View>
) : null}
{showIndicators && ( {showIndicators && (
<View style={styles.indicatorContainer} pointerEvents="box-none"> <View style={styles.indicatorContainer} pointerEvents="box-none">
<Pressable <Pressable
@@ -452,6 +498,12 @@ const Playbacks = () => {
</Pressable> </Pressable>
</View> </View>
)} )}
<View style={styles.feedHeader}>
<PlaybackTypeTabs
value={playbackType}
onChange={handlePlaybackTypeChange}
compact
/>
<Pressable <Pressable
style={styles.backToCharts} style={styles.backToCharts}
hitSlop={12} hitSlop={12}
@@ -462,6 +514,7 @@ const Playbacks = () => {
<Text style={styles.backToChartsText}>Retour au classement</Text> <Text style={styles.backToChartsText}>Retour au classement</Text>
</Pressable> </Pressable>
</View> </View>
</View>
// </Page> // </Page>
) )
} }
@@ -487,6 +540,17 @@ const styles = StyleSheet.create({
...StyleSheet.absoluteFillObject, ...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(2, 2, 2, 0.35)', backgroundColor: 'rgba(2, 2, 2, 0.35)',
}, },
feedHeader: {
position: 'absolute',
top: 28,
left: 24,
right: 24,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
zIndex: 20,
},
topOverlayContainer: { topOverlayContainer: {
position: 'absolute', position: 'absolute',
top: 32, top: 32,
@@ -571,9 +635,6 @@ const styles = StyleSheet.create({
textAlign: 'center', textAlign: 'center',
}, },
backToCharts: { backToCharts: {
position: 'absolute',
top: 28,
right: 24,
paddingVertical: 10, paddingVertical: 10,
paddingHorizontal: 14, paddingHorizontal: 14,
borderRadius: 999, borderRadius: 999,
@@ -595,4 +656,16 @@ const styles = StyleSheet.create({
fontSize: 13, fontSize: 13,
fontFamily: FONT_FAMILY.InterSemiBold, fontFamily: FONT_FAMILY.InterSemiBold,
}, },
emptyState: {
...StyleSheet.absoluteFillObject,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 24,
},
emptyStateText: {
color: Palette.white,
fontSize: 15,
fontFamily: FONT_FAMILY.InterMedium,
textAlign: 'center',
},
}) })
@@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { FlatList, Platform, ScrollView, StyleSheet, Text, View } from 'react-native' import { FlatList, Platform, ScrollView, StyleSheet, Text, View } from 'react-native'
import { BlurView } from 'expo-blur' import { BlurView } from 'expo-blur'
import { responsiveHeight } from 'react-native-responsive-dimensions' import { responsiveHeight } from 'react-native-responsive-dimensions'
import { background } from '../../../assets' import { background, img } from '../../../assets'
import BorderGradientButton from '../../../components/BorderGradientButton' import BorderGradientButton from '../../../components/BorderGradientButton'
import MobileCoinBadge from '../../../components/MobileCoinBadge' import MobileCoinBadge from '../../../components/MobileCoinBadge'
import SearchBar from '../../../components/SearchBar' import SearchBar from '../../../components/SearchBar'
@@ -17,7 +17,9 @@ import CreateLyricsHeader from '../../Writing/components/CreateLyricsHeader'
import SongCard from '../../HitParade/components/SongCard' import SongCard from '../../HitParade/components/SongCard'
import { Palette, Style } from '../../../styles' import { Palette, Style } from '../../../styles'
import { FONT_FAMILY } from '../../../styles/Fonts' import { FONT_FAMILY } from '../../../styles/Fonts'
import ClubCard from '../../Home/components/ClubCard' import SpaceLogoCard from '../../../components/SpaceLogoCard'
import PlaybackTypeTabs from './PlaybackTypeTabs'
import { matchesPlaybackType, PLAYBACK_TYPE } from '../../../utils/playbackType'
const allowedPremiumLevels = new Set(['starter', 'pro', 'premium']) const allowedPremiumLevels = new Set(['starter', 'pro', 'premium'])
@@ -37,7 +39,11 @@ const chunkArray = (items = [], size = 10) => {
return chunks return chunks
} }
const PlaybackCharts = ({ onPlaybackPress }) => { const PlaybackCharts = ({
onPlaybackPress,
playbackType = PLAYBACK_TYPE.LIVE,
onPlaybackTypeChange,
}) => {
const isWeb = Platform.OS === 'web' const isWeb = Platform.OS === 'web'
const [selectedCategory, setSelectedCategory] = useState('Playbacks') const [selectedCategory, setSelectedCategory] = useState('Playbacks')
const { const {
@@ -67,19 +73,25 @@ const PlaybackCharts = ({ onPlaybackPress }) => {
condition: true, condition: true,
}) })
const playbacksList = useMemo( const playbacksList = useMemo(() => {
() => (Array.isArray(allPlaybacks) ? allPlaybacks : []), const items = Array.isArray(allPlaybacks) ? allPlaybacks : []
[allPlaybacks] return items.filter((item) => matchesPlaybackType(item, playbackType))
) }, [allPlaybacks, playbackType])
const topMonthList = useMemo( const topMonthList = useMemo(
() => (Array.isArray(topMonthPlaybacks) ? topMonthPlaybacks : []), () => {
[topMonthPlaybacks] const items = Array.isArray(topMonthPlaybacks) ? topMonthPlaybacks : []
return items.filter((item) => matchesPlaybackType(item, playbackType))
},
[playbackType, topMonthPlaybacks]
) )
const allTimeList = useMemo( const allTimeList = useMemo(
() => (Array.isArray(allTimePlaybacks) ? allTimePlaybacks : []), () => {
[allTimePlaybacks] const items = Array.isArray(allTimePlaybacks) ? allTimePlaybacks : []
return items.filter((item) => matchesPlaybackType(item, playbackType))
},
[allTimePlaybacks, playbackType]
) )
const { const {
@@ -104,7 +116,10 @@ const PlaybackCharts = ({ onPlaybackPress }) => {
) )
const musicResults = useMemo(() => (Array.isArray(musics) ? musics : []), [musics]) const musicResults = useMemo(() => (Array.isArray(musics) ? musics : []), [musics])
const playbackResults = useMemo(() => (Array.isArray(playbacks) ? playbacks : []), [playbacks]) const playbackResults = useMemo(() => {
const items = Array.isArray(playbacks) ? playbacks : []
return items.filter((item) => matchesPlaybackType(item, playbackType))
}, [playbackType, playbacks])
const userResults = useMemo(() => (Array.isArray(users) ? users : []), [users]) const userResults = useMemo(() => (Array.isArray(users) ? users : []), [users])
const musicsLoading = searchLoading const musicsLoading = searchLoading
@@ -252,8 +267,18 @@ const PlaybackCharts = ({ onPlaybackPress }) => {
[onPlaybackPress] [onPlaybackPress]
) )
const heroTitle = isWeb ? 'Playbacks coups de coeur' : 'Classements des playbacks' const playbackTypeLabel = playbackType === PLAYBACK_TYPE.AI ? 'Playbacks IA' : 'Playbacks live'
const heroSubtitle = 'Découvre les playbacks les plus écoutés et lance la lecture verticale.' const heroTitle = isWeb ? `${playbackTypeLabel} coups de coeur` : `Classement des ${playbackTypeLabel.toLowerCase()}`
const heroSubtitle =
playbackType === PLAYBACK_TYPE.AI
? 'Découvre les playbacks créés avec l’intelligence artificielle.'
: 'Découvre les performances enregistrées en live.'
const renderEmptyState = () => (
<View style={styles.emptyState}>
<Text style={styles.emptyStateText}>Aucun {playbackTypeLabel.toLowerCase()} pour le moment.</Text>
</View>
)
const renderHero = () => ( const renderHero = () => (
<View style={{ gap: 11, maxWidth: 800, width: '100%', alignSelf: 'center' }}> <View style={{ gap: 11, maxWidth: 800, width: '100%', alignSelf: 'center' }}>
@@ -289,6 +314,7 @@ const PlaybackCharts = ({ onPlaybackPress }) => {
data={playbacksList} data={playbacksList}
keyExtractor={(item) => item.id} keyExtractor={(item) => item.id}
refreshing={allPlaybacksLoading} refreshing={allPlaybacksLoading}
ListEmptyComponent={renderEmptyState}
renderItem={({ item }) => ( renderItem={({ item }) => (
<SongCard <SongCard
rank={null} rank={null}
@@ -359,6 +385,7 @@ const PlaybackCharts = ({ onPlaybackPress }) => {
onPress={() => handlePlaybackSelected(item)} onPress={() => handlePlaybackSelected(item)}
/> />
))} ))}
{playbacksList.length === 0 ? renderEmptyState() : null}
</View> </View>
) )
@@ -435,7 +462,7 @@ const PlaybackCharts = ({ onPlaybackPress }) => {
fontFamily: FONT_FAMILY.HelveticaNeueBold, fontFamily: FONT_FAMILY.HelveticaNeueBold,
}} }}
> >
Playbacks {playbackTypeLabel}
</Text> </Text>
</BlurView> </BlurView>
<ScrollView <ScrollView
@@ -544,7 +571,7 @@ const PlaybackCharts = ({ onPlaybackPress }) => {
return ( return (
<Page <Page
backgroundImg={isWeb ? background.playbackBG2 : background.playbackBG} backgroundImg={background.playbackMainBackground}
headerType="NONE" headerType="NONE"
width="100%" width="100%"
maxWidth={1200} maxWidth={1200}
@@ -567,7 +594,13 @@ const PlaybackCharts = ({ onPlaybackPress }) => {
</View> </View>
) : null} ) : null}
<View style={{ gap: 12, flex: 1 }}> <View style={{ gap: 12, flex: 1 }}>
<ClubCard /> <SpaceLogoCard
source={img.musiclandPlayback}
accessibilityLabel="Playback MusicLand"
imageStyle={styles.playbackLogoImage}
/>
<PlaybackTypeTabs value={playbackType} onChange={onPlaybackTypeChange} />
{isWeb && ( {isWeb && (
<View <View
@@ -660,3 +693,20 @@ const PlaybackCharts = ({ onPlaybackPress }) => {
} }
export default PlaybackCharts export default PlaybackCharts
const styles = StyleSheet.create({
playbackLogoImage: {
transform: [{ scale: 1.02 }],
},
emptyState: {
paddingVertical: 32,
paddingHorizontal: 16,
alignItems: 'center',
},
emptyStateText: {
color: 'rgba(255,255,255,0.72)',
fontSize: 14,
fontFamily: FONT_FAMILY.InterMedium,
textAlign: 'center',
},
})
@@ -0,0 +1,88 @@
import React from 'react'
import { Pressable, StyleSheet, Text, View } from 'react-native'
import { Palette } from '../../../styles'
import { FONT_FAMILY } from '../../../styles/Fonts'
import { PLAYBACK_TYPE } from '../../../utils/playbackType'
const options = [
{ label: 'Playbacks live', value: PLAYBACK_TYPE.LIVE },
{ label: 'Playbacks IA', value: PLAYBACK_TYPE.AI },
]
const PlaybackTypeTabs = ({ value, onChange, compact = false, style }) => (
<View style={[styles.container, compact && styles.compactContainer, style]}>
{options.map((option) => {
const isSelected = value === option.value
return (
<Pressable
key={option.value}
accessibilityRole="tab"
accessibilityState={{ selected: isSelected }}
onPress={() => onChange?.(option.value)}
style={({ pressed }) => [
styles.tab,
compact && styles.compactTab,
isSelected && styles.selectedTab,
pressed && styles.pressedTab,
]}
>
<Text
style={[
styles.label,
compact && styles.compactLabel,
isSelected && styles.selectedLabel,
]}
>
{option.label}
</Text>
</Pressable>
)
})}
</View>
)
export default PlaybackTypeTabs
const styles = StyleSheet.create({
container: {
alignSelf: 'center',
flexDirection: 'row',
padding: 4,
borderRadius: 999,
backgroundColor: 'rgba(9, 6, 16, 0.62)',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.18)',
},
compactContainer: {
alignSelf: 'auto',
padding: 3,
},
tab: {
minHeight: 42,
justifyContent: 'center',
paddingHorizontal: 18,
borderRadius: 999,
},
compactTab: {
minHeight: 34,
paddingHorizontal: 11,
},
selectedTab: {
backgroundColor: Palette.white,
},
pressedTab: {
opacity: 0.78,
},
label: {
color: 'rgba(255,255,255,0.72)',
fontSize: 14,
fontFamily: FONT_FAMILY.InterSemiBold,
},
compactLabel: {
fontSize: 12,
},
selectedLabel: {
color: '#0F0C14',
},
})
+22 -2
View File
@@ -13,7 +13,7 @@ import firebase, { getFunctionsClient, projectsRef, serverTimestamp } from '../.
import { uploadFileToFirebase } from '../../helpers/uploadToFirebase' import { uploadFileToFirebase } from '../../helpers/uploadToFirebase'
import Page from '../../layouts/Page' import Page from '../../layouts/Page'
import { Routes } from '../../navigation' import { Routes } from '../../navigation'
import { goBack, navigate } from '../../navigation/NavigationService' import { navigate } from '../../navigation/NavigationService'
import { useUserData, useUser } from '../../providers/UserDataProvider' import { useUserData, useUser } from '../../providers/UserDataProvider'
import { useStripe } from '../../providers/StripeProvider' import { useStripe } from '../../providers/StripeProvider'
import { Palette } from '../../styles' import { Palette } from '../../styles'
@@ -391,6 +391,15 @@ const PlaybackDownload = ({ route }) => {
handleDownloadUri() handleDownloadUri()
}, [downloadPaymentPending, handleDownloadUri, hasPaidPlaybackDownload, isDownloading]) }, [downloadPaymentPending, handleDownloadUri, hasPaidPlaybackDownload, isDownloading])
const handleGoHome = useCallback(() => {
navigate(Routes.BottomTab, {
screen: Routes.HomeStack,
params: {
screen: Routes.Home,
},
})
}, [])
const handlePublish = async () => { const handlePublish = async () => {
if (!hasAcceptedPublication) return if (!hasAcceptedPublication) return
@@ -402,6 +411,7 @@ const PlaybackDownload = ({ route }) => {
await projectsRef.doc(projectForDownload.id).set( await projectsRef.doc(projectForDownload.id).set(
{ {
playbackUrl: url, playbackUrl: url,
playbackProvider: 'live',
updatedAt: serverTimestamp(), updatedAt: serverTimestamp(),
}, },
{ merge: true } { merge: true }
@@ -431,7 +441,7 @@ const PlaybackDownload = ({ route }) => {
backgroundColor="#07111B" backgroundColor="#07111B"
headerType="NONE" headerType="NONE"
> >
<MusicLandHeader onPressBack={goBack} progress={50} /> <MusicLandHeader onPressBack={handleGoHome} progress={50} />
<ScrollView contentContainerStyle={styles.container} showsVerticalScrollIndicator={false}> <ScrollView contentContainerStyle={styles.container} showsVerticalScrollIndicator={false}>
<CreateLyricsHeader title={'Publier ton playback'} /> <CreateLyricsHeader title={'Publier ton playback'} />
@@ -494,6 +504,12 @@ const PlaybackDownload = ({ route }) => {
loadingText="Publication en cours..." loadingText="Publication en cours..."
containerStyle={styles.continueButton} containerStyle={styles.continueButton}
/> />
<BorderGradientButton
title="Retour à l'accueil"
onPress={handleGoHome}
disabled={isPublishing || isDownloading}
containerStyle={styles.homeButton}
/>
</ScrollView> </ScrollView>
</Page> </Page>
</> </>
@@ -562,4 +578,8 @@ const styles = StyleSheet.create({
continueButton: { continueButton: {
marginTop: 10, marginTop: 10,
}, },
homeButton: {
marginTop: 2,
marginBottom: 18,
},
}) })
+8 -19
View File
@@ -1,6 +1,5 @@
import AsyncStorage from '@react-native-async-storage/async-storage' import AsyncStorage from '@react-native-async-storage/async-storage'
import * as AppleAuthentication from 'expo-apple-authentication' import * as AppleAuthentication from 'expo-apple-authentication'
import DateTimePicker from '@react-native-community/datetimepicker'
import React, { useCallback, useEffect, useState } from 'react' import React, { useCallback, useEffect, useState } from 'react'
import { useGlobal } from 'reactn' import { useGlobal } from 'reactn'
import { import {
@@ -19,6 +18,7 @@ import { background } from '../assets'
import BorderGradientButton from '../components/BorderGradientButton' import BorderGradientButton from '../components/BorderGradientButton'
import { Input } from '../components/Input' import { Input } from '../components/Input'
import ItemContainer from '../components/ItemContainer/ItemContainer' import ItemContainer from '../components/ItemContainer/ItemContainer'
import NativeDateModal from '../components/modal/NativeDateModal'
import WebDateModal from '../components/modal/WebDateModal' import WebDateModal from '../components/modal/WebDateModal'
import firebase from '../config/firebase' import firebase from '../config/firebase'
import { isWeb } from '../hooks/useLayoutType' import { isWeb } from '../hooks/useLayoutType'
@@ -130,13 +130,6 @@ const Register = ({ navigation }) => {
}) })
} }
const onDateChange = (event, selectedDate) => {
setShowDatePicker(Platform.OS === 'ios')
if (selectedDate) {
setBirthDate(selectedDate)
}
}
const renderFormContent = () => ( const renderFormContent = () => (
<View style={[styles.formContent, isCompactWeb ? styles.compactFormContent : null]}> <View style={[styles.formContent, isCompactWeb ? styles.compactFormContent : null]}>
<Text style={[styles.title, isCompactWeb ? styles.compactTitle : null]}> <Text style={[styles.title, isCompactWeb ? styles.compactTitle : null]}>
@@ -200,17 +193,13 @@ const Register = ({ navigation }) => {
/> />
</View> </View>
</Pressable> </Pressable>
{showDatePicker && ( <NativeDateModal
<DateTimePicker visible={showDatePicker}
testID="dateTimePicker" onClose={() => setShowDatePicker(false)}
value={birthDate || new Date()} onValidate={setBirthDate}
mode="date" initialDate={birthDate}
is24Hour={true} language={preferredLanguage}
display="default"
onChange={onDateChange}
maximumDate={new Date()}
/> />
)}
</> </>
)} )}
</View> </View>
@@ -248,7 +237,7 @@ const Register = ({ navigation }) => {
<Page <Page
width={isWeb ? '100%' : null} width={isWeb ? '100%' : null}
maxWidth={isWeb ? 640 : 1200} maxWidth={isWeb ? 640 : 1200}
backgroundImg={isWeb ? background.loginBgWeb : background.homeBG} backgroundImg={background.loginBgWeb}
headerType="NAVIGATION" headerType="NAVIGATION"
title="Inscription" title="Inscription"
onBackPressed={handleBack} onBackPressed={handleBack}
+1 -1
View File
@@ -55,7 +55,7 @@ const RegistrationChoice = () => {
return ( return (
<Page <Page
width={isWeb ? 720 : null} width={isWeb ? 720 : null}
backgroundImg={isWeb ? background.loginBgWeb : background.homeBG} backgroundImg={background.loginBgWeb}
headerType="NONE" headerType="NONE"
title="Choisis ton expérience" title="Choisis ton expérience"
> >
+2 -11
View File
@@ -1,9 +1,8 @@
import React, { useMemo, useRef, useState } from 'react' import React, { useMemo, useRef, useState } from 'react'
import { useIsFocused, useRoute } from '@react-navigation/native' import { useRoute } from '@react-navigation/native'
import { Dimensions, Modal, Text, View } from 'react-native' import { Dimensions, Modal, Text, View } from 'react-native'
import SwiperFlatList from 'react-native-swiper-flatlist' import SwiperFlatList from 'react-native-swiper-flatlist'
import { background, icons } from '../../assets' import { background, icons } from '../../assets'
import BackgroundVideo from '../../components/BackgroundVideo'
import BorderGradientButton from '../../components/BorderGradientButton' import BorderGradientButton from '../../components/BorderGradientButton'
import GradientButton from '../../components/GradientButton' import GradientButton from '../../components/GradientButton'
import MusicLandHeader from '../../components/MusicLandHeader' import MusicLandHeader from '../../components/MusicLandHeader'
@@ -37,7 +36,6 @@ const INSTRUMENT_STEP_INDEX = FIRST_VOICE_STEP_INDEX + VOICE_SECTION_TITLES.leng
const RHYTHM_STEP_INDEX = LAST_STEP_INDEX const RHYTHM_STEP_INDEX = LAST_STEP_INDEX
const ComposeSong = () => { const ComposeSong = () => {
const isFocused = useIsFocused()
const scrollRef = useRef(null) const scrollRef = useRef(null)
const [selectedIndex, setSelectedIndex] = useState(0) const [selectedIndex, setSelectedIndex] = useState(0)
const [progress, setProgress] = useState(18) const [progress, setProgress] = useState(18)
@@ -49,7 +47,6 @@ const ComposeSong = () => {
updateProjectData, updateProjectData,
currentUserData, currentUserData,
currentUID, currentUID,
videos,
} = useUser() } = useUser()
// Selections state // Selections state
@@ -294,13 +291,7 @@ const ComposeSong = () => {
]) ])
return ( return (
<Page <Page backgroundImg={background.studioBG2} headerType="NONE">
backgroundImg={background.studioBG2}
backgroundContent={
isFocused ? <BackgroundVideo source={videos?.malik} /> : null
}
headerType="NONE"
>
<MusicLandHeader onPressBack={onPressBack} progress={progress} logo={icons.musicLandStudio} /> <MusicLandHeader onPressBack={onPressBack} progress={progress} logo={icons.musicLandStudio} />
<View style={{ flex: 1, paddingBottom: gutters, gap: 48 }}> <View style={{ flex: 1, paddingBottom: gutters, gap: 48 }}>
<View <View
+46 -2
View File
@@ -30,6 +30,7 @@ const GeneratingSong = () => {
const isFocused = useIsFocused() const isFocused = useIsFocused()
const askedRef = useRef(false) const askedRef = useRef(false)
const callingRef = useRef(false) const callingRef = useRef(false)
const statusPollInFlightRef = useRef(false)
const localStartRef = useRef(null) const localStartRef = useRef(null)
const errorAlertShownRef = useRef(false) const errorAlertShownRef = useRef(false)
@@ -132,6 +133,46 @@ const GeneratingSong = () => {
} }
}, [selectedProject?.musicStatus]) }, [selectedProject?.musicStatus])
useEffect(() => {
const taskId = selectedProject?.sunoTaskId
if (!isFocused || selectedProject?.musicStatus !== 'GENERATING' || !taskId) return undefined
let isActive = true
const getSunoStatus = firebase.functions().httpsCallable('music-getSunoStatus')
const pollStatus = async () => {
if (!isActive || statusPollInFlightRef.current) return
statusPollInFlightRef.current = true
try {
const { data } = await getSunoStatus({ taskId })
if (isActive && data?.failed && selectedProjectId) {
await projectsRef.doc(selectedProjectId).set(
{
musicStatus: 'FAILED',
musicError: {
source: 'SUNO_API',
message: data?.errorMessage || 'La génération Suno a échoué.',
},
updatedAt: firebase.firestore.FieldValue.serverTimestamp(),
},
{ merge: true }
)
}
} catch (error) {
console.log('Suno status polling failed', error?.message || error)
} finally {
statusPollInFlightRef.current = false
}
}
pollStatus()
const interval = global.setInterval(pollStatus, 15000)
return () => {
isActive = false
global.clearInterval(interval)
statusPollInFlightRef.current = false
}
}, [isFocused, selectedProject?.musicStatus, selectedProject?.sunoTaskId, selectedProjectId])
const effectiveConfig = useMemo(() => { const effectiveConfig = useMemo(() => {
// Use selectedProject.musicConfig only // Use selectedProject.musicConfig only
const cfg = selectedProject?.musicConfig || {} const cfg = selectedProject?.musicConfig || {}
@@ -165,8 +206,11 @@ const GeneratingSong = () => {
tempo: cfg?.tempo, tempo: cfg?.tempo,
projectId: selectedProjectId || selectedProject?.id || null, projectId: selectedProjectId || selectedProject?.id || null,
}) })
const taskId = data?.response?.data?.taskId || data?.response?.data?.task_id const taskId = data?.response?.data?.taskId
if (selectedProjectId && taskId) { if (!taskId) {
throw new Error("Suno n'a pas retourné d'identifiant de génération.")
}
if (selectedProjectId) {
const baseUpdate = { const baseUpdate = {
sunoTaskId: taskId, sunoTaskId: taskId,
musicStatus: 'GENERATING', musicStatus: 'GENERATING',
+1
View File
@@ -122,6 +122,7 @@ const SongReady = () => {
coverUrl: null, coverUrl: null,
coverStatus: null, coverStatus: null,
playbackUrl: null, playbackUrl: null,
playbackProvider: null,
playbackStatus: null, playbackStatus: null,
playbackGenerating: null, playbackGenerating: null,
}) })
+2 -8
View File
@@ -3,7 +3,6 @@ import { Dimensions, KeyboardAvoidingView, Platform, View } from 'react-native'
import { responsiveHeight } from 'react-native-responsive-dimensions' import { responsiveHeight } from 'react-native-responsive-dimensions'
import { SwiperFlatList } from 'react-native-swiper-flatlist' import { SwiperFlatList } from 'react-native-swiper-flatlist'
import { background } from '../../assets' import { background } from '../../assets'
import BackgroundVideo from '../../components/BackgroundVideo'
import GradientButton from '../../components/GradientButton' import GradientButton from '../../components/GradientButton'
import MusicLandHeader from '../../components/MusicLandHeader' import MusicLandHeader from '../../components/MusicLandHeader'
import { OTHER_OBJECTIVE_OPTION, OTHER_STYLE_OPTION } from '../../data/data' import { OTHER_OBJECTIVE_OPTION, OTHER_STYLE_OPTION } from '../../data/data'
@@ -27,7 +26,7 @@ const CUSTOM_SANITIZE_OPTIONS = { autoInjectPreChorus: false }
const CTA_BUTTON_MAX_WIDTH = 500 const CTA_BUTTON_MAX_WIDTH = 500
const CreateLyricsWithAi = () => { const CreateLyricsWithAi = () => {
const { selectedProject, videos } = useUser() const { selectedProject } = useUser()
const scrollRef = useRef(null) const scrollRef = useRef(null)
const [selectedIndex, setSelectedIndex] = useState(0) const [selectedIndex, setSelectedIndex] = useState(0)
const [progress, setProgress] = useState(16) const [progress, setProgress] = useState(16)
@@ -342,15 +341,10 @@ const CreateLyricsWithAi = () => {
}, [selectedIndex, parentLayout?.height]) }, [selectedIndex, parentLayout?.height])
const isCtaVisible = selectedIndex !== 8 const isCtaVisible = selectedIndex !== 8
const isQuestionStep = selectedIndex < MAX_STEP_INDEX
return ( return (
<Page <Page
headerType="NONE" headerType="NONE"
backgroundImg={isQuestionStep ? null : background.writingMobileBackground} backgroundImg={background.writingMobileBackground}
backgroundContent={
isQuestionStep ? <BackgroundVideo source={videos?.celine} /> : null
}
> >
<View <View
onLayout={(event) => { onLayout={(event) => {
+1 -1
View File
@@ -20,7 +20,7 @@ import {
const FAKE_PROGRESS_MAX = 96 const FAKE_PROGRESS_MAX = 96
const PROGRESS_INTERVAL_MS = 250 const PROGRESS_INTERVAL_MS = 250
const CUSTOM_SANITIZE_OPTIONS = { autoInjectPreChorus: false } const CUSTOM_SANITIZE_OPTIONS = { autoInjectPreChorus: false }
const FUNCTIONS_REGION = 'europe-west1' const FUNCTIONS_REGION = 'us-central1'
const CreatingLyrics = ({ active, config, selections, onErrorRedirect }) => { const CreatingLyrics = ({ active, config, selections, onErrorRedirect }) => {
const [progress, setProgress] = useState(0) const [progress, setProgress] = useState(0)
+80 -6
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useMemo, useRef, useState } from 'react' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ScrollView, StyleSheet, Text, View } from 'react-native' import { ScrollView, StyleSheet, Text, View } from 'react-native'
import { background, icons } from '../../assets' import { background, icons } from '../../assets'
import alert from '../../components/Alert' import alert from '../../components/Alert'
@@ -31,7 +31,7 @@ import CustomInput from './components/CustomInput'
const RESPONSIBILITY_CHECKBOX_LABEL = const RESPONSIBILITY_CHECKBOX_LABEL =
'Vous êtes responsable du contenu que vous validez et Musicland ne sera en aucun cas tenu responsable du contenu que vous validez.' 'Vous êtes responsable du contenu que vous validez et Musicland ne sera en aucun cas tenu responsable du contenu que vous validez.'
const FUNCTIONS_REGION = 'europe-west1' const FUNCTIONS_REGION = 'us-central1'
const Lyrics = ({ navigation }) => { const Lyrics = ({ navigation }) => {
const scrollRef = useRef(null) const scrollRef = useRef(null)
@@ -142,6 +142,11 @@ const Lyrics = ({ navigation }) => {
const [titleValue, setTitleValue] = useState(initial.title || '') const [titleValue, setTitleValue] = useState(initial.title || '')
const [sections, setSections] = useState(initial.sections || []) const [sections, setSections] = useState(initial.sections || [])
const moderationRequestRef = useRef({
signature: '',
data: null,
promise: null,
})
const setSectionAt = (index, value) => { const setSectionAt = (index, value) => {
setSections((prev) => { setSections((prev) => {
const next = [...prev] const next = [...prev]
@@ -187,6 +192,77 @@ const Lyrics = ({ navigation }) => {
}) })
}, []) }, [])
const getModerationResult = useCallback(({ title, lyrics }) => {
const signature = JSON.stringify({ title, lyrics })
const cachedRequest = moderationRequestRef.current
if (cachedRequest.signature === signature) {
if (cachedRequest.data) return Promise.resolve(cachedRequest.data)
if (cachedRequest.promise) return cachedRequest.promise
}
const callable = getFunctionsClient(FUNCTIONS_REGION).httpsCallable(
'lyrics-analyseLyricsToxicity'
)
const requestPromise = callable({ title, lyrics })
.then(({ data }) => {
if (
moderationRequestRef.current.signature === signature &&
moderationRequestRef.current.promise === requestPromise
) {
moderationRequestRef.current = {
signature,
data,
promise: null,
}
}
return data
})
.catch((error) => {
if (
moderationRequestRef.current.signature === signature &&
moderationRequestRef.current.promise === requestPromise
) {
moderationRequestRef.current = {
signature,
data: null,
promise: null,
}
}
throw error
})
moderationRequestRef.current = {
signature,
data: null,
promise: requestPromise,
}
return requestPromise
}, [])
useEffect(() => {
const title = (titleValue || '').trim()
const lyrics = (sections || []).map((section) => ({
type: normalizeStructureType(section?.type),
lyrics: segmentRequiresLyrics(normalizeStructureType(section?.type))
? (section?.lyrics || '').trim()
: '',
}))
const hasIncompleteSection = lyrics.some(
(section) => segmentRequiresLyrics(section.type) && !section.lyrics
)
if (!title || hasIncompleteSection) return undefined
const timer = setTimeout(() => {
getModerationResult({ title, lyrics }).catch((error) => {
console.log('Background moderation failed', error?.message || error)
})
}, 400)
return () => clearTimeout(timer)
}, [getModerationResult, sections, titleValue])
const handleSensitiveCancel = useCallback(() => { const handleSensitiveCancel = useCallback(() => {
closeSensitiveContentModal(false) closeSensitiveContentModal(false)
}, [closeSensitiveContentModal]) }, [closeSensitiveContentModal])
@@ -240,10 +316,7 @@ const Lyrics = ({ navigation }) => {
// 1) Appel de la Cloud Function de modération avant tout enregistrement // 1) Appel de la Cloud Function de modération avant tout enregistrement
try { try {
const callable = getFunctionsClient(FUNCTIONS_REGION).httpsCallable( const data = await getModerationResult({
'lyrics-analyseLyricsToxicity'
)
const { data } = await callable({
title: titleTrimmed, title: titleTrimmed,
lyrics: normalizedNewLyrics, lyrics: normalizedNewLyrics,
}) })
@@ -375,6 +448,7 @@ const Lyrics = ({ navigation }) => {
projectSelections, projectSelections,
alertMessage, alertMessage,
confirmSensitiveContent, confirmSensitiveContent,
getModerationResult,
sanitize, sanitize,
selectedProjectId, selectedProjectId,
projectHasLyrics, projectHasLyrics,
+23 -6
View File
@@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { StyleSheet, Text, View } from 'react-native' import { StyleSheet, Text, View } from 'react-native'
import useMinuit from 'react-native-minuit/src/hooks/useMinuit.js' import useMinuit from 'react-native-minuit/src/hooks/useMinuit.js'
import { background } from '../../assets' import { background, videos } from '../../assets'
import GradientButton from '../../components/GradientButton' import GradientButton from '../../components/GradientButton'
import MusicLandHeader from '../../components/MusicLandHeader' import MusicLandHeader from '../../components/MusicLandHeader'
import { strings } from '../../constants/strings' import { strings } from '../../constants/strings'
@@ -18,15 +18,26 @@ const CTA_BUTTON_HEIGHT = 58
const CTA_BUTTON_MAX_WIDTH = 520 const CTA_BUTTON_MAX_WIDTH = 520
const WritingLyrics = () => { const WritingLyrics = () => {
const { createNewProject, selectedProjectId, updateProjectData, videos } = useUserData() const { createNewProject, currentUserData, selectedProjectId, updateProjectData } =
useUserData()
const { setIsLoading } = useMinuit() const { setIsLoading } = useMinuit()
const [videoUrl, setVideoUrl] = useState(null) const [videoUrl, setVideoUrl] = useState(null)
const preferredLanguage = currentUserData
? currentUserData.preferredLanguage === 'en'
? 'en'
: 'fr'
: null
useEffect(() => { useEffect(() => {
setVideoUrl(isWeb ? videos.celineWeb : videos?.celine) if (!preferredLanguage) {
// setVideoUrl(videos?.test); return
}, []) }
setVideoUrl(
preferredLanguage === 'en' ? videos.writingSpaceIntroEn : videos.writingSpaceIntroFr
)
}, [preferredLanguage])
const startWriting = React.useCallback(async () => { const startWriting = React.useCallback(async () => {
try { try {
@@ -66,7 +77,13 @@ const WritingLyrics = () => {
<Text style={styles.secondaryNote}>{strings.writing.onboarding.secondaryNote}</Text> <Text style={styles.secondaryNote}>{strings.writing.onboarding.secondaryNote}</Text>
</View> </View>
</View> </View>
<FullscreenIntroVideo url={videoUrl} visible={!!videoUrl} onClose={() => setVideoUrl(null)} /> <FullscreenIntroVideo
url={videoUrl}
visible={!!videoUrl}
onClose={() => setVideoUrl(null)}
contentFit={isWeb ? 'contain' : 'cover'}
skipLabel={preferredLanguage === 'en' ? 'Skip video' : 'Passer la vidéo'}
/>
</Page> </Page>
) )
} }
+74 -6
View File
@@ -1,4 +1,5 @@
import { BlurView } from 'expo-blur' import { BlurView } from 'expo-blur'
import * as ImagePicker from 'expo-image-picker'
import React, { useCallback, useEffect, useMemo, useState } from 'react' import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Modal, Platform, Pressable, StyleSheet, Text, View } from 'react-native' import { Modal, Platform, Pressable, StyleSheet, Text, View } from 'react-native'
import useMinuit from 'react-native-minuit/src/hooks/useMinuit' import useMinuit from 'react-native-minuit/src/hooks/useMinuit'
@@ -7,6 +8,7 @@ import GradientButton from '../../components/GradientButton'
import { Input } from '../../components/Input' import { Input } from '../../components/Input'
import MusicLandHeader from '../../components/MusicLandHeader' import MusicLandHeader from '../../components/MusicLandHeader'
import firebase, { projectsRef, usersRef } from '../../config/firebase' import firebase, { projectsRef, usersRef } from '../../config/firebase'
import { uploadFileToFirebase } from '../../helpers/uploadToFirebase'
import Page from '../../layouts/Page' import Page from '../../layouts/Page'
import { Routes } from '../../navigation' import { Routes } from '../../navigation'
import { goBack, navigate } from '../../navigation/NavigationService' import { goBack, navigate } from '../../navigation/NavigationService'
@@ -16,6 +18,8 @@ import { FONT_FAMILY } from '../../styles/Fonts'
import { buildFullName } from '../../utils/artistName' import { buildFullName } from '../../utils/artistName'
import { background } from '../../assets' import { background } from '../../assets'
const COVER_TEMPLATE = 'MUSICLAND_PLAYBACKER'
export const ChooseCoverType = () => { export const ChooseCoverType = () => {
const isIOS = Platform.OS === 'ios' const isIOS = Platform.OS === 'ios'
const [choiceVisible, setChoiceVisible] = useState(false) const [choiceVisible, setChoiceVisible] = useState(false)
@@ -27,8 +31,14 @@ export const ChooseCoverType = () => {
const [savingChoice, setSavingChoice] = useState(false) const [savingChoice, setSavingChoice] = useState(false)
const [savingPseudo, setSavingPseudo] = useState(false) const [savingPseudo, setSavingPseudo] = useState(false)
const { selectedProject, selectedProjectId, currentUserData, currentUID } = useUserData() const {
const { setTooltip } = useMinuit() selectedProject,
selectedProjectId,
updateProjectData,
currentUserData,
currentUID,
} = useUserData()
const { setIsLoading, setTooltip } = useMinuit()
const projectId = selectedProject?.id || selectedProjectId || null const projectId = selectedProject?.id || selectedProjectId || null
const hasArtistPreference = useMemo(() => { const hasArtistPreference = useMemo(() => {
@@ -104,6 +114,64 @@ export const ChooseCoverType = () => {
ensureArtistPreference(() => navigate(Routes.PouchReady)) ensureArtistPreference(() => navigate(Routes.PouchReady))
}, [ensureArtistPreference]) }, [ensureArtistPreference])
const pickCenterImage = useCallback(async () => {
try {
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsEditing: true,
aspect: [1, 1],
quality: 1,
})
if (result.canceled) return
const uri = result.assets?.[0]?.uri
if (!uri || !projectId) {
throw new Error("Impossible d'identifier l'image ou le projet")
}
await setIsLoading(true)
const { resultURI } = await uploadFileToFirebase({
uri,
path: `musics/${projectId}/cover-center-image.jpg`,
shouldCompress: false,
fileType: 'IMAGE',
})
if (!resultURI) {
throw new Error("Le téléversement de l'image a échoué")
}
await updateProjectData(
{
coverCenterImageURL: resultURI,
coverStatus: 'PENDING',
cover: {
template: COVER_TEMPLATE,
result: null,
generatedBackground: null,
selectedOptionId: COVER_TEMPLATE,
options: [],
},
},
{ merge: true }
)
navigate(Routes.PouchReady)
} catch (error) {
console.log('pickCenterImage error', error?.message)
setTooltip({
type: 'error',
text: error?.message || "Impossible de sélectionner l'image",
})
} finally {
await setIsLoading(false)
}
}, [projectId, setIsLoading, setTooltip, updateProjectData])
const handlePickCenterImage = useCallback(() => {
ensureArtistPreference(pickCenterImage)
}, [ensureArtistPreference, pickCenterImage])
const closeChoiceModal = useCallback(() => { const closeChoiceModal = useCallback(() => {
setChoiceVisible(false) setChoiceVisible(false)
setChoiceDismissAction(null) setChoiceDismissAction(null)
@@ -259,10 +327,10 @@ export const ChooseCoverType = () => {
gap: 12, gap: 12,
}} }}
> >
{/* <BorderGradientButton <BorderGradientButton
title="Choisir une image" title="Choisir l’image centrale"
onPress={handlePickUserImage} onPress={handlePickCenterImage}
/> */} />
<GradientButton <GradientButton
title="Voir ma pochette" title="Voir ma pochette"
onPress={handleShowCover} onPress={handleShowCover}
+9 -4
View File
@@ -40,6 +40,7 @@ const PouchReady = () => {
? selectedProject.userName.trim() ? selectedProject.userName.trim()
: currentUserData?.userName || 'MusicLand' : currentUserData?.userName || 'MusicLand'
const profilePictureURL = currentUserData?.profilePictureURL || null const profilePictureURL = currentUserData?.profilePictureURL || null
const centerImageURL = selectedProject?.coverCenterImageURL || profilePictureURL
const selectedOption = useMemo(() => { const selectedOption = useMemo(() => {
if (!coverUrl) { if (!coverUrl) {
@@ -119,7 +120,7 @@ const PouchReady = () => {
} }
const coverBlob = await createMusiclandPlaybackCover({ const coverBlob = await createMusiclandPlaybackCover({
artistName, artistName,
profilePictureURL, profilePictureURL: centerImageURL,
}) })
const storagePath = `users/${userId}/projects/${selectedProjectId}/cover-musicland-playbacker.png` const storagePath = `users/${userId}/projects/${selectedProjectId}/cover-musicland-playbacker.png`
const { resultURI } = await uploadFileToFirebase({ const { resultURI } = await uploadFileToFirebase({
@@ -171,7 +172,7 @@ const PouchReady = () => {
}, [ }, [
artistName, artistName,
coverUrl, coverUrl,
profilePictureURL, centerImageURL,
selectedOption, selectedOption,
selectedProject, selectedProject,
selectedProjectId, selectedProjectId,
@@ -184,12 +185,16 @@ const PouchReady = () => {
<View style={styles.pageContent}> <View style={styles.pageContent}>
<CreateLyricsHeader <CreateLyricsHeader
title="Ta pochette MusicLand" title="Ta pochette MusicLand"
subTitle="Ta photo de profil et ton nom d’artiste apparaissent automatiquement." subTitle={
selectedProject?.coverCenterImageURL
? "L’image choisie et ton nom d’artiste apparaissent automatiquement."
: "Ta photo de profil et ton nom d’artiste apparaissent automatiquement."
}
/> />
<View style={[styles.previewArea, Style.containerCenter]}> <View style={[styles.previewArea, Style.containerCenter]}>
<MusiclandPlaybackCover <MusiclandPlaybackCover
artistName={artistName} artistName={artistName}
profilePictureURL={profilePictureURL} profilePictureURL={centerImageURL}
coverUrl={coverUrl} coverUrl={coverUrl}
style={isWeb ? styles.coverWeb : styles.coverMobile} style={isWeb ? styles.coverWeb : styles.coverMobile}
/> />
+10
View File
@@ -0,0 +1,10 @@
export const PLAYBACK_TYPE = Object.freeze({
LIVE: 'live',
AI: 'ai',
})
export const getPlaybackType = (project) =>
project?.playbackProvider === 'heygen' ? PLAYBACK_TYPE.AI : PLAYBACK_TYPE.LIVE
export const matchesPlaybackType = (project, playbackType) =>
getPlaybackType(project) === playbackType
+14 -12
View File
@@ -24,6 +24,19 @@ export const getRouteAfterAuthentication = (userData = {}) =>
? Routes.RegistrationChoice ? Routes.RegistrationChoice
: Routes.BottomTab : Routes.BottomTab
export const getMainMenuReset = () => ({
index: 0,
routes: [
{
name: Routes.BottomTab,
params: {
screen: Routes.HomeStack,
params: { screen: Routes.Home },
},
},
],
})
export const getExperienceReset = (experience) => { export const getExperienceReset = (experience) => {
if (experience === 'listen') { if (experience === 'listen') {
return { return {
@@ -37,18 +50,7 @@ export const getExperienceReset = (experience) => {
} }
} }
return { return getMainMenuReset()
index: 0,
routes: [
{
name: Routes.BottomTab,
params: {
screen: Routes.HomeStack,
params: { screen: Routes.Home },
},
},
],
}
} }
export const completeRegistrationFlow = async (experience) => { export const completeRegistrationFlow = async (experience) => {
+3 -7
View File
@@ -1,15 +1,11 @@
{ {
"$schema": "https://openapi.vercel.sh/vercel.json",
"rewrites": [ "rewrites": [
{ "source": "/link", "destination": "/index.html" }, { "source": "/(.*)", "destination": "/index.html" }
{ "source": "/link/:path*", "destination": "/index.html" },
{ "source": "/music/:musicId", "destination": "/index.html" },
{ "source": "/tasks/:taskId", "destination": "/index.html" },
{ "source": "/playback/:playbackId", "destination": "/index.html" },
{ "source": "/playbacks/:playbackId", "destination": "/index.html" }
], ],
"headers": [ "headers": [
{ {
"source": "/.web/well-known/apple-app-site-association", "source": "/.well-known/apple-app-site-association",
"headers": [ "headers": [
{ {
"key": "Content-Type", "key": "Content-Type",
@@ -1,18 +0,0 @@
{
"applinks": {
"apps": [],
"details": [
{
"appID": "W2QZ9CTMYJ.com.omedis.musicland.ai",
"paths": [
"/music/*"
]
}
]
},
"webcredentials": {
"apps": [
"W2QZ9CTMYJ.com.omedis.musicland.ai"
]
}
}