Refactor app flows and refresh UI components
@@ -4,8 +4,11 @@
|
||||
# Node/Expo caches and build artifacts
|
||||
node_modules/
|
||||
.expo/
|
||||
.vercel/
|
||||
.env*
|
||||
dist/
|
||||
web-build/
|
||||
builds/
|
||||
npm-debug.*
|
||||
yarn-error.log
|
||||
|
||||
@@ -30,4 +33,3 @@ ios/Pods/
|
||||
# Do NOT ignore native project roots here
|
||||
# android/
|
||||
# ios/
|
||||
|
||||
|
||||
@@ -80,3 +80,5 @@ dist/
|
||||
|
||||
# @end expo-cli
|
||||
*.sh
|
||||
.vercel
|
||||
.env*
|
||||
|
||||
@@ -6,12 +6,12 @@
|
||||
"scheme": [
|
||||
"musicland",
|
||||
"com.googleusercontent.apps.305598753437-qck73beo37mi04qlautgafbpdl3ccaoh",
|
||||
"com.googleusercontent.apps.305598753437-t2uitr9gd7aaed59ahdvklkcsg0vngmk"
|
||||
"com.googleusercontent.apps.305598753437-820ot12hdlpfkvhb33lj56jp4752p3un"
|
||||
],
|
||||
"deeplinks": [
|
||||
"musicland://"
|
||||
],
|
||||
"version": "2026.04.08",
|
||||
"version": "1.0",
|
||||
"icon": "./assets/icon.png",
|
||||
"backgroundColor": "#0F0C14",
|
||||
"jsEngine": "hermes",
|
||||
@@ -27,6 +27,8 @@
|
||||
"backgroundColor": "#000"
|
||||
},
|
||||
"ios": {
|
||||
"appleTeamId": "SD7SD54VR9",
|
||||
"buildNumber": "6",
|
||||
"googleServicesFile": "./config/GoogleService-Info.plist",
|
||||
"supportsTablet": true,
|
||||
"usesAppleSignIn": true,
|
||||
@@ -35,7 +37,7 @@
|
||||
"associatedDomains": [
|
||||
"applinks:musicland-one.vercel.app"
|
||||
],
|
||||
"bundleIdentifier": "com.omedis.musicland.ai",
|
||||
"bundleIdentifier": "Ai.musicland",
|
||||
"infoPlist": {
|
||||
"UIBackgroundModes": [
|
||||
"audio"
|
||||
@@ -58,12 +60,11 @@
|
||||
"usesNonExemptEncryption": false
|
||||
},
|
||||
"entitlements": {
|
||||
"aps-environment": "development",
|
||||
"aps-environment": "production",
|
||||
"com.apple.developer.applesignin": [
|
||||
"Default"
|
||||
]
|
||||
},
|
||||
"appleTeamId": "UH4Z9T6UXN"
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"softwareKeyboardLayoutMode": "resize",
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CLIENT_ID</key>
|
||||
<string>305598753437-t2uitr9gd7aaed59ahdvklkcsg0vngmk.apps.googleusercontent.com</string>
|
||||
<string>305598753437-820ot12hdlpfkvhb33lj56jp4752p3un.apps.googleusercontent.com</string>
|
||||
<key>REVERSED_CLIENT_ID</key>
|
||||
<string>com.googleusercontent.apps.305598753437-t2uitr9gd7aaed59ahdvklkcsg0vngmk</string>
|
||||
<string>com.googleusercontent.apps.305598753437-820ot12hdlpfkvhb33lj56jp4752p3un</string>
|
||||
<key>ANDROID_CLIENT_ID</key>
|
||||
<string>305598753437-2oe78um7k9fagc9cjrb9vurldn5a0a4c.apps.googleusercontent.com</string>
|
||||
<key>API_KEY</key>
|
||||
@@ -15,7 +15,7 @@
|
||||
<key>PLIST_VERSION</key>
|
||||
<string>1</string>
|
||||
<key>BUNDLE_ID</key>
|
||||
<string>com.omedis.musicland.ai</string>
|
||||
<string>Ai.musicland</string>
|
||||
<key>PROJECT_ID</key>
|
||||
<string>musicland-d33f9</string>
|
||||
<key>STORAGE_BUCKET</key>
|
||||
@@ -31,6 +31,6 @@
|
||||
<key>IS_SIGNIN_ENABLED</key>
|
||||
<true></true>
|
||||
<key>GOOGLE_APP_ID</key>
|
||||
<string>1:305598753437:ios:7942793c4093dedff28274</string>
|
||||
<string>1:305598753437:ios:4a6410dbe02ece31f28274</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -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.
|
||||
|
||||
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
|
||||
|
||||
@@ -21,9 +21,17 @@
|
||||
"resourceClass": "large"
|
||||
},
|
||||
"ios": {
|
||||
"image": "latest",
|
||||
"buildConfiguration": "Release",
|
||||
"autoIncrement": "buildNumber"
|
||||
}
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {
|
||||
"ios": {
|
||||
"ascAppId": "6796655773"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,10 +52,106 @@ function basicTemplate({ title = '', content = '', button = null }) {
|
||||
</body></html>`
|
||||
}
|
||||
|
||||
function welcomeTemplate({ firstName = '', lastName = '' }) {
|
||||
const fullName = [firstName, lastName].filter(Boolean).join(' ').trim()
|
||||
const greeting = fullName ? `Salut ${fullName},` : 'Salut,'
|
||||
return `<!doctype html><html lang="fr" style="background-color:#0b0b10"><head>
|
||||
function cleanEmailName(firstName) {
|
||||
return typeof firstName === 'string' ? firstName.replace(/[\r\n]+/g, ' ').trim() : ''
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
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 name="color-scheme" content="dark" />
|
||||
<meta name="supported-color-schemes" content="dark" />
|
||||
@@ -86,16 +182,7 @@ function welcomeTemplate({ firstName = '', lastName = '' }) {
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
<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>
|
||||
${content}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -107,4 +194,5 @@ function welcomeTemplate({ firstName = '', lastName = '' }) {
|
||||
}
|
||||
|
||||
exports.basicTemplate = basicTemplate
|
||||
exports.welcomeEmailSubject = welcomeEmailSubject
|
||||
exports.welcomeTemplate = welcomeTemplate
|
||||
|
||||
@@ -7,8 +7,46 @@ const { setTimeout } = require('timers/promises')
|
||||
|
||||
// --- CONFIGURATION ---
|
||||
// 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 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) ---
|
||||
// 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()
|
||||
|
||||
try {
|
||||
const { output } = await ai.generate({
|
||||
model: googleAI.model(TEXT_MODEL_NAME),
|
||||
system,
|
||||
prompt,
|
||||
output: { schema },
|
||||
config: {
|
||||
temperature: 0.7, // Créativité équilibrée
|
||||
const { output } = await generateTextWithRetry(
|
||||
ai,
|
||||
{
|
||||
system,
|
||||
prompt,
|
||||
output: { schema },
|
||||
config: {
|
||||
temperature: 0.7, // Créativité équilibrée
|
||||
},
|
||||
},
|
||||
})
|
||||
'generateAI'
|
||||
)
|
||||
|
||||
return output
|
||||
} catch (error) {
|
||||
@@ -97,7 +138,7 @@ exports.analyseLyrics = async ({ title = '', lyrics }) => {
|
||||
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).'),
|
||||
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
|
||||
.array(
|
||||
z.object({
|
||||
@@ -106,7 +147,7 @@ exports.analyseLyrics = async ({ title = '', lyrics }) => {
|
||||
severity: z.string(),
|
||||
})
|
||||
)
|
||||
.max(10),
|
||||
.max(3),
|
||||
success: z.boolean(),
|
||||
})
|
||||
|
||||
@@ -133,27 +174,31 @@ ${lyricsText}
|
||||
const startedAt = Date.now()
|
||||
|
||||
try {
|
||||
const { output } = await ai.generate({
|
||||
model: googleAI.model(TEXT_MODEL_NAME),
|
||||
system,
|
||||
prompt: userPrompt,
|
||||
output: { schema: moderationSchema },
|
||||
config: {
|
||||
// Paramètres de sécurité permissifs pour laisser l'IA voir et juger le contenu
|
||||
safetySettings: [
|
||||
{ category: 'HARM_CATEGORY_HATE_SPEECH', threshold: 'BLOCK_NONE' },
|
||||
{
|
||||
category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT',
|
||||
threshold: 'BLOCK_NONE',
|
||||
},
|
||||
{
|
||||
category: 'HARM_CATEGORY_DANGEROUS_CONTENT',
|
||||
threshold: 'BLOCK_NONE',
|
||||
},
|
||||
{ category: 'HARM_CATEGORY_HARASSMENT', threshold: 'BLOCK_NONE' },
|
||||
],
|
||||
const { output } = await generateTextWithRetry(
|
||||
ai,
|
||||
{
|
||||
system,
|
||||
prompt: userPrompt,
|
||||
output: { schema: moderationSchema },
|
||||
config: {
|
||||
temperature: 0,
|
||||
// Paramètres de sécurité permissifs pour laisser l'IA voir et juger le contenu
|
||||
safetySettings: [
|
||||
{ category: 'HARM_CATEGORY_HATE_SPEECH', threshold: 'BLOCK_NONE' },
|
||||
{
|
||||
category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT',
|
||||
threshold: 'BLOCK_NONE',
|
||||
},
|
||||
{
|
||||
category: 'HARM_CATEGORY_DANGEROUS_CONTENT',
|
||||
threshold: 'BLOCK_NONE',
|
||||
},
|
||||
{ category: 'HARM_CATEGORY_HARASSMENT', threshold: 'BLOCK_NONE' },
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
'analyseLyrics'
|
||||
)
|
||||
|
||||
if (!output) throw new Error("Échec de l'analyse de modération.")
|
||||
|
||||
|
||||
@@ -92,9 +92,13 @@ const buildCircularMask = (size) => {
|
||||
async function loadCoverIdentity(project) {
|
||||
const artistName = normalizeArtistName(project.userName)
|
||||
const userId = typeof project.userId === 'string' ? project.userId.trim() : ''
|
||||
const centerImageURL =
|
||||
typeof project.coverCenterImageURL === 'string' && project.coverCenterImageURL.trim()
|
||||
? project.coverCenterImageURL.trim()
|
||||
: null
|
||||
|
||||
if (!userId) {
|
||||
return { artistName, profilePictureURL: null }
|
||||
return { artistName, profilePictureURL: centerImageURL }
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -102,14 +106,14 @@ async function loadCoverIdentity(project) {
|
||||
const userData = userSnapshot.exists ? userSnapshot.data() : null
|
||||
return {
|
||||
artistName: normalizeArtistName(project.userName || userData?.userName),
|
||||
profilePictureURL: userData?.profilePictureURL || null,
|
||||
profilePictureURL: centerImageURL || userData?.profilePictureURL || null,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('[Cover] Unable to load profile picture', {
|
||||
projectId: project.id,
|
||||
error: error.message,
|
||||
})
|
||||
return { artistName, profilePictureURL: null }
|
||||
return { artistName, profilePictureURL: centerImageURL }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ const projectsRef = db.collection('projects')
|
||||
const REGION = process.env.FIREBASE_REGION || 'europe-west1'
|
||||
|
||||
const HEYGEN_BASE_URL = 'https://api.heygen.com/v3'
|
||||
const HEYGEN_REQUEST_TIMEOUT_MS = 45_000
|
||||
const PLAYBACK_STATUS = {
|
||||
GENERATING: 'GENERATING',
|
||||
DRAFT_READY: 'DRAFT_READY',
|
||||
@@ -65,9 +66,10 @@ const buildPlaybackReadyNotificationId = ({ projectId, videoId }) => {
|
||||
return `playback-ready-${digest}`
|
||||
}
|
||||
|
||||
const getHeygenHeaders = () => ({
|
||||
const getHeygenHeaders = (idempotencyKey = null) => ({
|
||||
'Content-Type': 'application/json',
|
||||
'X-Api-Key': getSecretValue(HEYGEN_API_KEY, 'HEYGEN_API_KEY'),
|
||||
...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}),
|
||||
})
|
||||
|
||||
const getProjectSnapshot = async ({ projectId, uid }) => {
|
||||
@@ -120,6 +122,7 @@ const findProjectForWebhook = async ({ callbackId, videoId }) => {
|
||||
const fetchCanonicalVideo = async (videoId) => {
|
||||
const response = await axios.get(`${HEYGEN_BASE_URL}/videos/${videoId}`, {
|
||||
headers: getHeygenHeaders(),
|
||||
timeout: HEYGEN_REQUEST_TIMEOUT_MS,
|
||||
})
|
||||
|
||||
return response?.data?.data || null
|
||||
@@ -210,7 +213,8 @@ exports.startPlaybackGeneration = onCall(
|
||||
callback_id: callbackId,
|
||||
},
|
||||
{
|
||||
headers: getHeygenHeaders(),
|
||||
headers: getHeygenHeaders(callbackId),
|
||||
timeout: HEYGEN_REQUEST_TIMEOUT_MS,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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 axios = require('axios')
|
||||
const { FieldValue } = require('firebase-admin/firestore')
|
||||
const { REGION, refList } = require('../index')
|
||||
const { refList } = require('../index')
|
||||
|
||||
// --- CONSTANTES DE STRUCTURE ---
|
||||
// On garde ces mappings car ils sont utiles pour normaliser l'input utilisateur
|
||||
@@ -59,13 +59,22 @@ const STRUCTURE_ALIASES = {
|
||||
soft_transition: 'transition_douce',
|
||||
}
|
||||
|
||||
const LYRICS_FUNCTION_OPTIONS = {
|
||||
region: REGION,
|
||||
const LYRICS_FUNCTION_BASE_OPTIONS = {
|
||||
cors: true,
|
||||
invoker: 'public',
|
||||
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 ---
|
||||
|
||||
const normalizeStructureValue = (value) => {
|
||||
@@ -144,7 +153,7 @@ const buildModerationBrief = ({
|
||||
|
||||
// --- GENERATION DE PAROLES (MAIN) ---
|
||||
|
||||
exports.generateLyrics = onCall(LYRICS_FUNCTION_OPTIONS, async ({ auth = {}, data = {} }) => {
|
||||
exports.generateLyrics = onCall(LYRICS_GENERATION_OPTIONS, async ({ auth = {}, data = {} }) => {
|
||||
try {
|
||||
const {
|
||||
lang: rawLang,
|
||||
@@ -204,9 +213,8 @@ exports.generateLyrics = onCall(LYRICS_FUNCTION_OPTIONS, async ({ auth = {}, dat
|
||||
}
|
||||
} catch (moderationError) {
|
||||
if (moderationError instanceof HttpsError) throw moderationError
|
||||
console.error('⚠️ Moderation check error (fail open)', moderationError)
|
||||
// On continue si l'appel modération fail (fail open) ou on throw (fail closed) selon ta politique.
|
||||
// Ici je fail closed par sécurité pour une app publique.
|
||||
console.error('⚠️ Moderation check error (fail closed)', moderationError)
|
||||
// En cas d'indisponibilité du pré-contrôle, on bloque la génération par sécurité.
|
||||
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 {
|
||||
const requestSchema = z.object({
|
||||
title: z.string().optional(),
|
||||
@@ -440,7 +448,7 @@ async function getSunoTimestamps(projectId) {
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${SUNO_API_KEY.value()}`,
|
||||
Authorization: `Bearer ${SUNO_API_KEY.value().trim()}`,
|
||||
},
|
||||
timeout: 30000,
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ const { onCall, onRequest, HttpsError } = require('firebase-functions/v2/https')
|
||||
const axios = require('axios')
|
||||
const admin = require('firebase-admin')
|
||||
const { FieldValue } = require('firebase-admin/firestore')
|
||||
const { logger } = require('firebase-functions/logger')
|
||||
const logger = require('firebase-functions/logger')
|
||||
const { pipeline } = require('stream/promises')
|
||||
const { randomUUID } = require('crypto')
|
||||
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)
|
||||
// On regarde si on trouve 'male' ou 'female' dans les tags générés
|
||||
let vGender = null
|
||||
if (optimizedStyle.includes('Female')) vGender = 'female'
|
||||
else if (optimizedStyle.includes('Male')) vGender = 'male'
|
||||
if (optimizedStyle.includes('Female')) vGender = 'f'
|
||||
else if (optimizedStyle.includes('Male')) vGender = 'm'
|
||||
|
||||
console.log('🎵 [Suno Optimisation] Result:', {
|
||||
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, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${SUNO_API_KEY.value()}`,
|
||||
Authorization: `Bearer ${SUNO_API_KEY.value().trim()}`,
|
||||
},
|
||||
})
|
||||
|
||||
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 {
|
||||
success: !!parsed?.data?.taskId,
|
||||
success: true,
|
||||
request: {
|
||||
model: payload.model,
|
||||
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 = {} }) => {
|
||||
const { taskId } = data
|
||||
if (!taskId) throw new Error('TaskId manquant')
|
||||
try {
|
||||
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,
|
||||
})
|
||||
const statusData = response.data?.data
|
||||
const reconciliation = await reconcileSunoStatus({ taskId, statusData })
|
||||
return {
|
||||
success: true,
|
||||
taskId,
|
||||
data: response.data?.data || response.data,
|
||||
status: statusData?.status || null,
|
||||
...reconciliation,
|
||||
}
|
||||
} catch (error) {
|
||||
if (taskId.includes('test'))
|
||||
|
||||
@@ -5,7 +5,7 @@ const { refList } = require('../index')
|
||||
const { ORDER_TYPES, createOrderDocument } = require('./helpers/orders')
|
||||
const { deleteFolder } = require('../helpers/firebase')
|
||||
const { Resend } = require('resend')
|
||||
const { welcomeTemplate } = require('../helpers/email')
|
||||
const { welcomeEmailSubject, welcomeTemplate } = require('../helpers/email')
|
||||
const { RESEND_API_KEY } = require('../config/secrets')
|
||||
const { onRequest } = require('firebase-functions/https')
|
||||
|
||||
@@ -19,7 +19,6 @@ const getResendClient = () => {
|
||||
}
|
||||
|
||||
const WELCOME_EMAIL_FROM = 'MusicLand <musicland@musicland.ai>'
|
||||
const WELCOME_EMAIL_SUBJECT = 'Bienvenue sur MusicLand'
|
||||
const USER_CREATED_OPTIONS = {
|
||||
document: 'users/{userID}',
|
||||
secrets: [RESEND_API_KEY],
|
||||
@@ -33,12 +32,12 @@ exports.testWelcomMail = onRequest({ secrets: [RESEND_API_KEY] }, async (req, re
|
||||
try {
|
||||
const targetEmail = req.query.email || 'tdtomthomas@gmail.com'
|
||||
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({
|
||||
from: WELCOME_EMAIL_FROM,
|
||||
to: [targetEmail],
|
||||
subject: WELCOME_EMAIL_SUBJECT,
|
||||
html: welcomeTemplate({ firstName, lastName }),
|
||||
subject: welcomeEmailSubject({ firstName, preferredLanguage }),
|
||||
html: welcomeTemplate({ firstName, preferredLanguage }),
|
||||
})
|
||||
if (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) => {
|
||||
try {
|
||||
const { email = '', firstName = '', lastName = '' } = event?.data?.data() || {}
|
||||
const { email = '', firstName = '', preferredLanguage = 'fr' } = event?.data?.data() || {}
|
||||
|
||||
const userId = event?.params?.userID
|
||||
|
||||
@@ -79,8 +78,8 @@ exports.onUserCreated = onDocumentCreated(USER_CREATED_OPTIONS, async (event) =>
|
||||
const { data, error } = await getResendClient().emails.send({
|
||||
from: WELCOME_EMAIL_FROM,
|
||||
to: [email],
|
||||
subject: WELCOME_EMAIL_SUBJECT,
|
||||
html: welcomeTemplate({ firstName, lastName }),
|
||||
subject: welcomeEmailSubject({ firstName, preferredLanguage }),
|
||||
html: welcomeTemplate({ firstName, preferredLanguage }),
|
||||
})
|
||||
if (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"]
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 262 KiB |
|
Before Width: | Height: | Size: 657 KiB After Width: | Height: | Size: 634 KiB |
@@ -220,17 +220,19 @@ export const background = {
|
||||
productionBG2,
|
||||
playbackBG,
|
||||
playbackBG2,
|
||||
playbackMainBackground: require('./UI/playbackMainBackground.jpg'),
|
||||
libraryBG,
|
||||
writingMobileBackground,
|
||||
libraryBgWeb: require('./UI/libraryBgWeb.png'),
|
||||
libraryMainBackground: require('./UI/libraryMainBackground.jpg'),
|
||||
libraryBG2,
|
||||
libraryBG2Web: require('./UI/libraryBG2Web.png'),
|
||||
profileBG,
|
||||
profileBgWeb: require('./UI/profileBgWeb.png'),
|
||||
hitParadeBG,
|
||||
hitParadeBG2: require('./UI/hitparadeBG2.jpg'),
|
||||
playbackWeb: require('./UI/playbackWeb.png'),
|
||||
playbackMobile: require('./UI/playbackMobile.png'),
|
||||
playbackWeb: require('./UI/videoSpaceBackground.jpg'),
|
||||
playbackMobile: require('./UI/videoSpaceBackground.jpg'),
|
||||
homeBG,
|
||||
homeBGWeb: require('./UI/homeBGWeb.png'),
|
||||
loginBgWeb: require('./UI/loginBgWeb.png'),
|
||||
@@ -251,6 +253,11 @@ export const videos = {
|
||||
Platform.OS === 'web' ? require('./video/testVideoWeb.mp4') : require('./video/testVideo.mp4'),
|
||||
club: require('./video/club.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'),
|
||||
welcomeEn: require('./video/welcome-en.mp4'),
|
||||
}
|
||||
@@ -264,6 +271,8 @@ export const img = {
|
||||
goodVibe,
|
||||
musiclandClub,
|
||||
musiclandPlaybackCover,
|
||||
musiclandPlayback: require('./UI/musiclandPlayback.png'),
|
||||
musiclandStreaming: require('./UI/musiclandStreaming.png'),
|
||||
}
|
||||
|
||||
export const cardsImg = {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { VideoView, useVideoPlayer } from 'expo-video'
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native'
|
||||
|
||||
const BackgroundVideo = ({ source }) => {
|
||||
const BackgroundVideo = ({ source, silent = false }) => {
|
||||
const [muted, setMuted] = useState(false)
|
||||
const resolvedSource = useMemo(
|
||||
() => (source ? (typeof source === 'string' ? { uri: source } : source) : null),
|
||||
@@ -20,9 +20,9 @@ const BackgroundVideo = ({ source }) => {
|
||||
return
|
||||
}
|
||||
|
||||
player.muted = muted
|
||||
player.muted = silent || muted
|
||||
player.play()
|
||||
}, [muted, player, source])
|
||||
}, [muted, player, silent, source])
|
||||
|
||||
if (!source) {
|
||||
return null
|
||||
@@ -37,19 +37,23 @@ const BackgroundVideo = ({ source }) => {
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
<View pointerEvents="none" style={styles.scrim} />
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={muted ? 'Activer le son' : 'Couper le son'}
|
||||
onPress={() => setMuted((currentMuted) => !currentMuted)}
|
||||
style={({ pressed }) => [styles.soundButton, pressed && styles.soundButtonPressed]}
|
||||
>
|
||||
<MaterialIcons
|
||||
name={muted ? 'volume-off' : 'volume-up'}
|
||||
size={20}
|
||||
color="#FFFFFF"
|
||||
/>
|
||||
<Text style={styles.soundButtonText}>{muted ? 'Activer le son' : 'Couper le son'}</Text>
|
||||
</Pressable>
|
||||
{!silent ? (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={muted ? 'Activer le son' : 'Couper le son'}
|
||||
onPress={() => setMuted((currentMuted) => !currentMuted)}
|
||||
style={({ pressed }) => [styles.soundButton, pressed && styles.soundButtonPressed]}
|
||||
>
|
||||
<MaterialIcons
|
||||
name={muted ? 'volume-off' : 'volume-up'}
|
||||
size={20}
|
||||
color="#FFFFFF"
|
||||
/>
|
||||
<Text style={styles.soundButtonText}>
|
||||
{muted ? 'Activer le son' : 'Couper le son'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Pressable, StyleSheet, Text, View } from 'react-native'
|
||||
|
||||
import { resolveMediaUri } from '../utils/resolveMediaUri'
|
||||
|
||||
const BackgroundVideo = ({ source, soundButtonStyle = null }) => {
|
||||
const BackgroundVideo = ({ source, soundButtonStyle = null, silent = false }) => {
|
||||
const videoRef = useRef(null)
|
||||
const [uri, setUri] = useState(null)
|
||||
const [muted, setMuted] = useState(false)
|
||||
@@ -63,11 +63,11 @@ const BackgroundVideo = ({ source, soundButtonStyle = null }) => {
|
||||
return
|
||||
}
|
||||
|
||||
video.muted = muted
|
||||
video.muted = silent || muted
|
||||
if (video.paused) {
|
||||
video.play()?.catch?.(() => {})
|
||||
}
|
||||
}, [muted])
|
||||
}, [muted, silent])
|
||||
|
||||
if (!uri) {
|
||||
return null
|
||||
@@ -82,28 +82,32 @@ const BackgroundVideo = ({ source, soundButtonStyle = null }) => {
|
||||
playsInline
|
||||
autoPlay
|
||||
loop
|
||||
muted={muted}
|
||||
muted={silent || muted}
|
||||
controls={false}
|
||||
/>
|
||||
<View pointerEvents="none" style={styles.scrim} />
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={muted ? 'Activer le son' : 'Couper le son'}
|
||||
onPress={() => setMuted((currentMuted) => !currentMuted)}
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.soundButton,
|
||||
soundButtonStyle,
|
||||
hovered && styles.soundButtonHovered,
|
||||
pressed && styles.soundButtonPressed,
|
||||
]}
|
||||
>
|
||||
<MaterialIcons
|
||||
name={muted ? 'volume-off' : 'volume-up'}
|
||||
size={20}
|
||||
color="#FFFFFF"
|
||||
/>
|
||||
<Text style={styles.soundButtonText}>{muted ? 'Activer le son' : 'Couper le son'}</Text>
|
||||
</Pressable>
|
||||
{!silent ? (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={muted ? 'Activer le son' : 'Couper le son'}
|
||||
onPress={() => setMuted((currentMuted) => !currentMuted)}
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.soundButton,
|
||||
soundButtonStyle,
|
||||
hovered && styles.soundButtonHovered,
|
||||
pressed && styles.soundButtonPressed,
|
||||
]}
|
||||
>
|
||||
<MaterialIcons
|
||||
name={muted ? 'volume-off' : 'volume-up'}
|
||||
size={20}
|
||||
color="#FFFFFF"
|
||||
/>
|
||||
<Text style={styles.soundButtonText}>
|
||||
{muted ? 'Activer le son' : 'Couper le son'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { videos } from '../assets'
|
||||
import { Portal } from '@gorhom/portal'
|
||||
@@ -96,7 +96,6 @@ const FullscreenIntroVideo = ({
|
||||
pointerEvents="box-none"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
paddingHorizontal: 10,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
@@ -111,15 +110,7 @@ const FullscreenIntroVideo = ({
|
||||
player={player}
|
||||
nativeControls={false}
|
||||
contentFit={contentFit}
|
||||
height="100%"
|
||||
width="100%"
|
||||
// style={{
|
||||
// position: "absolute",
|
||||
// top: 0,
|
||||
// bottom: 0,
|
||||
// left: 24,
|
||||
// right: 24,
|
||||
// }}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
{allowSkip ? (
|
||||
<Pressable
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -9,8 +9,10 @@ import { Platform } from 'react-native'
|
||||
|
||||
const functionsInstances = {}
|
||||
const emulatorConfigured = {}
|
||||
const emulatorHost = Platform.OS === 'web' ? 'localhost' : '192.168.1.60'
|
||||
const USE_FUNCTIONS_EMULATOR = __DEV__
|
||||
const emulatorHost =
|
||||
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 shouldUseEmulator = USE_FUNCTIONS_EMULATOR && instance?.useEmulator
|
||||
|
||||
@@ -88,13 +88,14 @@ const ensureUserDocument = async (user, overrides = {}, isNewUser = false) => {
|
||||
payload.createdAt = firebase.firestore.FieldValue.serverTimestamp()
|
||||
if (user.email) payload.email = user.email
|
||||
payload.emailNotifications = true
|
||||
if (isNewUser) {
|
||||
payload.registrationFlowRequired = true
|
||||
}
|
||||
} else if (typeof existingData?.emailNotifications === 'undefined') {
|
||||
payload.emailNotifications = true
|
||||
}
|
||||
|
||||
if (isNewUser) {
|
||||
payload.registrationFlowRequired = false
|
||||
}
|
||||
|
||||
const overrideFirstName =
|
||||
typeof overrides?.firstName === 'string' ? overrides.firstName.trim() : null
|
||||
const overrideLastName =
|
||||
|
||||
@@ -72,7 +72,7 @@ export const BottomTabScreen = () => {
|
||||
name={Routes.Library}
|
||||
component={Library}
|
||||
options={{
|
||||
tabBarLabel: 'Librairie',
|
||||
tabBarLabel: 'My library',
|
||||
headerShown: false,
|
||||
tabBarShowLabel: true,
|
||||
tabBarIcon: ({ focused }) => renderIcon(tabs.albums, focused),
|
||||
|
||||
@@ -7,10 +7,24 @@ import { useGlobal } from 'reactn'
|
||||
import firebase, {
|
||||
arrayUnion,
|
||||
notificationsRef,
|
||||
projectsRef,
|
||||
serverTimestamp,
|
||||
usersRef,
|
||||
} from '../config/firebase'
|
||||
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)
|
||||
|
||||
@@ -19,11 +33,14 @@ export default function NotificationProvider({ children }) {
|
||||
const [uid] = useGlobal('currentUID')
|
||||
|
||||
const [pendingData, setPendingData] = useGlobal('pendingData')
|
||||
const [activeRouteName] = useGlobal('activeRouteName')
|
||||
const [notifInit, setNotifInit] = useState(false)
|
||||
const [allowNotifications, setAllowNotifications] = useState(false)
|
||||
|
||||
const notificationListener = useRef()
|
||||
const responseListener = useRef()
|
||||
const handledResponseId = useRef(null)
|
||||
const handledNavigationKey = useRef(null)
|
||||
|
||||
const notificationsQuery = useMemo(() => {
|
||||
if (!uid) {
|
||||
@@ -149,24 +166,29 @@ export default function NotificationProvider({ children }) {
|
||||
return
|
||||
}
|
||||
notificationListener.current = Notifications.addNotificationReceivedListener(
|
||||
async (notification) => {
|
||||
(notification) => {
|
||||
console.log('Notification received', notification)
|
||||
await Notifications.scheduleNotificationAsync(notification?.request)
|
||||
}
|
||||
)
|
||||
|
||||
responseListener.current = Notifications.addNotificationResponseReceivedListener(
|
||||
async (response) => {
|
||||
console.log('Notification response', response)
|
||||
if (response?.actionIdentifier === 'expo.modules.notifications.actions.DEFAULT') {
|
||||
const { data } = response.notification.request.content
|
||||
if (data) {
|
||||
console.log('Notification data', data)
|
||||
await setPendingData(data)
|
||||
}
|
||||
const handleNotificationResponse = 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)
|
||||
if (response?.actionIdentifier === Notifications.DEFAULT_ACTION_IDENTIFIER) {
|
||||
const data = response?.notification?.request?.content?.data || null
|
||||
if (data) {
|
||||
console.log('Notification data', data)
|
||||
await setPendingData(data)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Notifications.getLastNotificationResponseAsync().then(handleNotificationResponse)
|
||||
responseListener.current =
|
||||
Notifications.addNotificationResponseReceivedListener(handleNotificationResponse)
|
||||
|
||||
return () => {
|
||||
Notifications.removeNotificationSubscription(notificationListener.current)
|
||||
@@ -174,6 +196,44 @@ export default function NotificationProvider({ children }) {
|
||||
}
|
||||
}, [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() {
|
||||
try {
|
||||
const isWeb = Platform.OS === 'web'
|
||||
|
||||
@@ -10,6 +10,14 @@ import { UserDataContext } from './UserDataProvider'
|
||||
const APP_SCHEME = 'musicland'
|
||||
const APP_PATH_PREFIX = 'app'
|
||||
const APP_STORE_FALLBACK_URL = appleAppStoreUrl
|
||||
const NATIVE_DEEP_LINK_RESOURCES = new Set([
|
||||
'music',
|
||||
'musics',
|
||||
'playback',
|
||||
'playbacks',
|
||||
'task',
|
||||
'tasks',
|
||||
])
|
||||
|
||||
const normalizePath = (rawPath = '') => {
|
||||
if (!rawPath) return ''
|
||||
@@ -27,6 +35,14 @@ const buildAppSchemeUrl = (rawPath, queryParams = {}) => {
|
||||
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 { currentUID } = useContext(UserDataContext)
|
||||
const { isFullyLoaded } = useContext(SplashAnimationContext)
|
||||
@@ -137,7 +153,7 @@ const UniversalLinkProvider = ({ children }) => {
|
||||
const isIOSBrowser =
|
||||
/iPad|iPhone|iPod/.test(userAgent) ||
|
||||
(/Macintosh/.test(userAgent) && navigator.maxTouchPoints && navigator.maxTouchPoints > 2)
|
||||
if (isIOSBrowser) {
|
||||
if (isIOSBrowser && shouldOpenNativeApp(path)) {
|
||||
try {
|
||||
const canOpen = await Linking.canOpenURL(appSchemeURL)
|
||||
if (canOpen) {
|
||||
|
||||
@@ -12,10 +12,10 @@ import { checkIfPasswordIsStrongEnough, handleFirebaseError } from '../actions/s
|
||||
import firebase, { usersRef } from '../config/firebase'
|
||||
import Page from '../layouts/Page'
|
||||
import { isWeb } from '../hooks/useLayoutType'
|
||||
import { Routes } from '../navigation'
|
||||
import { reset } from '../navigation/NavigationService'
|
||||
import { Palette } from '../styles'
|
||||
import { FONT_FAMILY } from '../styles/Fonts'
|
||||
import { getMainMenuReset } from '../utils/registrationFlow'
|
||||
|
||||
const CreatePassword = () => {
|
||||
const route = useRoute()
|
||||
@@ -115,14 +115,14 @@ const CreatePassword = () => {
|
||||
...(birthDate ? { birthDate } : {}),
|
||||
...(country?.code ? { countryCode: country.code } : {}),
|
||||
...(country?.name ? { countryName: country.name } : {}),
|
||||
registrationFlowRequired: true,
|
||||
registrationFlowRequired: false,
|
||||
createdAt: firebase.firestore.FieldValue.serverTimestamp(),
|
||||
updatedAt: firebase.firestore.FieldValue.serverTimestamp(),
|
||||
},
|
||||
{ merge: true }
|
||||
)
|
||||
setTooltip({ text: 'Compte créé, continuons', type: 'success' })
|
||||
reset({ index: 0, routes: [{ name: Routes.RegistrationChoice }] })
|
||||
setTooltip({ text: 'Compte créé', type: 'success' })
|
||||
reset(getMainMenuReset())
|
||||
} catch (e) {
|
||||
console.log('Register error', e?.message)
|
||||
const message =
|
||||
@@ -138,7 +138,7 @@ const CreatePassword = () => {
|
||||
return (
|
||||
<Page
|
||||
width={isWeb ? 600 : null}
|
||||
backgroundImg={isWeb ? background.loginBgWeb : background.homeBG}
|
||||
backgroundImg={background.loginBgWeb}
|
||||
headerType="NAVIGATION"
|
||||
title="Inscription"
|
||||
>
|
||||
|
||||
@@ -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 { BlurView } from 'expo-blur'
|
||||
import { responsiveHeight } from 'react-native-responsive-dimensions'
|
||||
import { background, icons } from '../../assets'
|
||||
import { background, img } from '../../assets'
|
||||
import BorderGradientButton from '../../components/BorderGradientButton'
|
||||
import SearchBar from '../../components/SearchBar'
|
||||
import ShareBtn from '../../components/ShareBtn/ShareBtn'
|
||||
@@ -18,7 +18,7 @@ import MobileCoinBadge from '../../components/MobileCoinBadge'
|
||||
import ResearchHeader from '../Library/components/ResearchHeader'
|
||||
import SearchResultsList from '../Library/components/SearchResultsList'
|
||||
import SongCard from './components/SongCard'
|
||||
import ClubCard from '../Home/components/ClubCard'
|
||||
import SpaceLogoCard from '../../components/SpaceLogoCard'
|
||||
|
||||
const allowedPremiumLevels = new Set(['starter', 'pro', 'premium'])
|
||||
const languageOptions = [
|
||||
@@ -451,11 +451,25 @@ const HitParade = () => {
|
||||
})
|
||||
}, [navigateToMusicDetails, songsList])
|
||||
|
||||
const heroTitle = isWeb
|
||||
? 'Coups de coeur des utilisateurs'
|
||||
: `Coups de coeur des utilisateurs catégorie ${selectedCategory}`
|
||||
|
||||
const heroSubtitle = 'Les 3 musiques les plus écoutées du mois reçoivent des tokens.'
|
||||
const heroContent = {
|
||||
Chansons: {
|
||||
title: 'Nouveautés Streaming',
|
||||
subtitle: 'Découvre les morceaux publiés par la communauté MusicLand.',
|
||||
},
|
||||
'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 = () => (
|
||||
<View style={{ gap: 11, maxWidth: 800, width: '100%', alignSelf: 'center' }}>
|
||||
@@ -666,11 +680,10 @@ const HitParade = () => {
|
||||
</View>
|
||||
) : null}
|
||||
<View style={{ gap: 12, flex: 1 }}>
|
||||
{/* <Image
|
||||
source={icons.musicLandLogo}
|
||||
style={{ alignSelf: 'center', height: 150, resizeMode: 'contain' }}
|
||||
/> */}
|
||||
<ClubCard />
|
||||
<SpaceLogoCard
|
||||
source={img.musiclandStreaming}
|
||||
accessibilityLabel="Streaming MusicLand"
|
||||
/>
|
||||
<StreamingLanguageFilter
|
||||
selectedLanguage={selectedLanguage}
|
||||
onSelectLanguage={setSelectedLanguage}
|
||||
|
||||
@@ -65,9 +65,12 @@ const STAGE_CARD_CONTENT = [
|
||||
{
|
||||
key: 'director',
|
||||
step: 'ÉTAPE 3',
|
||||
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!',
|
||||
description: {
|
||||
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,
|
||||
imageStyle: { transform: [{ translateY: 10 }] },
|
||||
imagePosition: 'left',
|
||||
textAlign: 'left',
|
||||
lockSide: 'right',
|
||||
@@ -75,8 +78,10 @@ const STAGE_CARD_CONTENT = [
|
||||
{
|
||||
key: 'publisher',
|
||||
step: 'ÉTAPE 4',
|
||||
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 !',
|
||||
description: {
|
||||
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,
|
||||
imagePosition: 'right',
|
||||
textAlign: 'right',
|
||||
@@ -101,6 +106,7 @@ const Home = ({ navigation, route }) => {
|
||||
const { setTooltip } = useMinuit()
|
||||
const [videoUrl, setVideoUrl] = useState(null)
|
||||
const navigateToMusicDetails = useNavigateToMusicDetails()
|
||||
const preferredLanguage = currentUserData?.preferredLanguage === 'en' ? 'en' : 'fr'
|
||||
|
||||
if (!currentUID) {
|
||||
return <LandingPage />
|
||||
@@ -324,7 +330,9 @@ const Home = ({ navigation, route }) => {
|
||||
const songwriterAction = useMemo(() => getStageAction('songwriter', null), [])
|
||||
|
||||
const handleStartNew = useCallback(async () => {
|
||||
const targetRoute = songwriterAction?.route || Routes.CreateLyricsWithAi
|
||||
const targetRoute = isWeb
|
||||
? songwriterAction?.route || Routes.CreateLyricsWithAi
|
||||
: Routes.WritingLyrics
|
||||
const targetParams = songwriterAction?.params
|
||||
|
||||
try {
|
||||
@@ -400,9 +408,13 @@ const Home = ({ navigation, route }) => {
|
||||
() =>
|
||||
STAGE_CARD_CONTENT.map((card) => ({
|
||||
...card,
|
||||
description:
|
||||
typeof card.description === 'string'
|
||||
? card.description
|
||||
: card.description[preferredLanguage],
|
||||
isLocked: stageStatesByKey[card.key]?.isLocked ?? true,
|
||||
})),
|
||||
[stageStatesByKey]
|
||||
[preferredLanguage, stageStatesByKey]
|
||||
)
|
||||
|
||||
const handleStagePress = useCallback(
|
||||
@@ -423,8 +435,12 @@ const Home = ({ navigation, route }) => {
|
||||
if (!action?.route) {
|
||||
return
|
||||
}
|
||||
const targetRoute =
|
||||
!isWeb && stageKey === 'songwriter' && action.route === Routes.CreateLyricsWithAi
|
||||
? Routes.WritingLyrics
|
||||
: action.route
|
||||
console.log("action route is ", action.route)
|
||||
navigate(action.route, action.params)
|
||||
navigate(targetRoute, action.params)
|
||||
},
|
||||
[currentProject, ensureProjectSelected, hasActiveProject, handleStartNew]
|
||||
)
|
||||
@@ -448,6 +464,7 @@ const Home = ({ navigation, route }) => {
|
||||
step={card.step}
|
||||
description={card.description}
|
||||
image={card.image}
|
||||
imageStyle={card.imageStyle}
|
||||
imagePosition={card.imagePosition}
|
||||
textAlign={card.textAlign}
|
||||
isLocked={card.isLocked}
|
||||
|
||||
@@ -16,7 +16,7 @@ const ClubCard = ({ onPress, containerStyle = null, variant = 'compact' }) => {
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Club MusicLand"
|
||||
accessibilityLabel="Streaming MusicLand"
|
||||
style={({ pressed }) => [
|
||||
styles.card,
|
||||
cardBaseStyle,
|
||||
@@ -24,7 +24,7 @@ const ClubCard = ({ onPress, containerStyle = null, variant = 'compact' }) => {
|
||||
pressed && styles.cardPressed,
|
||||
]}
|
||||
>
|
||||
<Image source={img.musiclandClub} style={imageStyle} />
|
||||
<Image source={img.musiclandStreaming} style={imageStyle} />
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ const StageCard = ({
|
||||
step,
|
||||
description,
|
||||
image,
|
||||
imageStyle = null,
|
||||
imagePosition = 'left',
|
||||
textAlign = 'left',
|
||||
isLocked,
|
||||
@@ -79,6 +80,7 @@ const StageCard = ({
|
||||
: isImageOnLeft
|
||||
? { alignSelf: 'flex-end' }
|
||||
: { alignSelf: 'flex-start' },
|
||||
imageStyle,
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
@@ -206,6 +208,9 @@ const StageCard = ({
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
disabled={isLocked}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`${step} — ${description}`}
|
||||
accessibilityState={{ disabled: !!isLocked }}
|
||||
style={({ pressed }) => [
|
||||
basePressableStyle,
|
||||
containerStyle,
|
||||
|
||||
@@ -2,7 +2,9 @@ import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { Image, StyleSheet, Text, TouchableOpacity, View } from 'react-native'
|
||||
import { background, icons, videos } from '../assets'
|
||||
import BackgroundVideo from '../components/BackgroundVideo'
|
||||
import FullscreenIntroVideo from '../components/FullscreenIntroVideo'
|
||||
import { isWeb } from '../hooks/useLayoutType'
|
||||
import Page from '../layouts/Page'
|
||||
import { Routes } from '../navigation'
|
||||
import { navigate, reset } from '../navigation/NavigationService'
|
||||
@@ -85,7 +87,15 @@ export default function LandingPage({ route }) {
|
||||
|
||||
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
|
||||
style={{
|
||||
flex: 1,
|
||||
@@ -121,7 +131,7 @@ export default function LandingPage({ route }) {
|
||||
url={activeVideo}
|
||||
visible
|
||||
onClose={activeVideoClose}
|
||||
contentFit="contain"
|
||||
contentFit={isWeb || isJingle ? 'contain' : 'cover'}
|
||||
allowSkip={!isJingle}
|
||||
requireAudio={!isJingle}
|
||||
offerSoundActivation={isJingle}
|
||||
|
||||
@@ -48,7 +48,7 @@ const Library = () => {
|
||||
hasLikedClips
|
||||
|
||||
return (
|
||||
<Page backgroundImg={background.libraryBG} headerType="NONE">
|
||||
<Page backgroundImg={background.libraryMainBackground} headerType="NONE">
|
||||
{Platform.OS !== 'web' ? (
|
||||
<View
|
||||
style={{
|
||||
|
||||
@@ -111,7 +111,7 @@ const Library = () => {
|
||||
}, [dropdownVisible, closeDropdown])
|
||||
|
||||
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 }}>
|
||||
<Image
|
||||
source={icons.musicLandLogo}
|
||||
|
||||
@@ -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 { useIsFocused } from '@react-navigation/native'
|
||||
import { background } from '../../assets'
|
||||
import BackgroundVideo from '../../components/BackgroundVideo'
|
||||
import { background, videos } from '../../assets'
|
||||
import BorderGradientButton from '../../components/BorderGradientButton'
|
||||
import FullscreenIntroVideo from '../../components/FullscreenIntroVideo'
|
||||
import GradientButton from '../../components/GradientButton'
|
||||
import MusicLandHeader from '../../components/MusicLandHeader'
|
||||
import Page from '../../layouts/Page'
|
||||
import { isWeb } from '../../hooks/useLayoutType'
|
||||
import { Routes } from '../../navigation'
|
||||
import { goBack, navigate } from '../../navigation/NavigationService'
|
||||
import { useUser, useUserData } from '../../providers/UserDataProvider'
|
||||
import { useUserData } from '../../providers/UserDataProvider'
|
||||
import { gutters, Palette } from '../../styles'
|
||||
import {
|
||||
getPlaybackAiStatusLabel,
|
||||
@@ -19,11 +18,25 @@ import {
|
||||
} from '../../utils/playbackAi'
|
||||
|
||||
const Playback = ({ route, navigation }) => {
|
||||
const isFocused = useIsFocused()
|
||||
const { project: routeProject, returnToAdventureModal = false } = route.params || {}
|
||||
const { videos } = useUser()
|
||||
const { selectedProject } = useUserData() || {}
|
||||
const theoUrl = isWeb ? videos?.theoWeb : videos?.theo
|
||||
const { currentUserData, selectedProject } = useUserData() || {}
|
||||
const [videoUrl, setVideoUrl] = useState(null)
|
||||
const preferredLanguage = currentUserData
|
||||
? currentUserData.preferredLanguage === 'en'
|
||||
? 'en'
|
||||
: 'fr'
|
||||
: null
|
||||
|
||||
useEffect(() => {
|
||||
if (!preferredLanguage) {
|
||||
return
|
||||
}
|
||||
|
||||
setVideoUrl(
|
||||
preferredLanguage === 'en' ? videos.videoSpaceIntroEn : videos.videoSpaceIntroFr
|
||||
)
|
||||
}, [preferredLanguage])
|
||||
|
||||
const project = useMemo(() => {
|
||||
if (routeProject?.id && selectedProject?.id === routeProject.id) {
|
||||
return selectedProject
|
||||
@@ -61,14 +74,6 @@ const Playback = ({ route, navigation }) => {
|
||||
<Page
|
||||
headerType="NONE"
|
||||
backgroundImg={isWeb ? background.playbackWeb : background.playbackMobile}
|
||||
backgroundContent={
|
||||
isFocused ? (
|
||||
<BackgroundVideo
|
||||
source={theoUrl}
|
||||
soundButtonStyle={isWeb ? styles.rightSideControl : undefined}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
containerStyle={isWeb ? styles.videoPage : undefined}
|
||||
>
|
||||
{/* <Image source={ai.theo} style={styles.img} resizeMode="contain" /> */}
|
||||
@@ -103,6 +108,13 @@ const Playback = ({ route, navigation }) => {
|
||||
{/* <BorderGradientButton title="Importer une vidéo" /> */}
|
||||
</View>
|
||||
</View>
|
||||
<FullscreenIntroVideo
|
||||
url={videoUrl}
|
||||
visible={!!videoUrl}
|
||||
onClose={() => setVideoUrl(null)}
|
||||
contentFit={isWeb ? 'contain' : 'cover'}
|
||||
skipLabel={preferredLanguage === 'en' ? 'Skip video' : 'Passer la vidéo'}
|
||||
/>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
@@ -114,10 +126,6 @@ const styles = StyleSheet.create({
|
||||
alignSelf: 'flex-start',
|
||||
marginLeft: '4%',
|
||||
},
|
||||
rightSideControl: {
|
||||
right: 24,
|
||||
left: 'auto',
|
||||
},
|
||||
blockedText: {
|
||||
color: Palette.white,
|
||||
opacity: 0.75,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as ImagePicker from 'expo-image-picker'
|
||||
import useMinuit from 'react-native-minuit/src/hooks/useMinuit.js'
|
||||
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 ActivityLoader from '../../components/ActivityLoader'
|
||||
import BorderGradientButton from '../../components/BorderGradientButton'
|
||||
@@ -108,7 +108,7 @@ const PlaybackAi = ({ route }) => {
|
||||
throw new Error("Impossible d'envoyer la photo vers Firebase")
|
||||
}
|
||||
|
||||
await callStartPlaybackGeneration({
|
||||
const generationRequest = callStartPlaybackGeneration({
|
||||
projectId: project.id,
|
||||
photoUrl,
|
||||
})
|
||||
@@ -121,6 +121,8 @@ const PlaybackAi = ({ route }) => {
|
||||
playbackPhotoUrl: photoUrl,
|
||||
},
|
||||
})
|
||||
|
||||
await generationRequest
|
||||
} catch (error) {
|
||||
setTooltip({
|
||||
type: 'error',
|
||||
@@ -133,7 +135,7 @@ const PlaybackAi = ({ route }) => {
|
||||
|
||||
return (
|
||||
<Page
|
||||
backgroundImg={background.playbackBG2}
|
||||
backgroundImg={Platform.OS === 'web' ? background.playbackWeb : background.playbackMobile}
|
||||
backgroundColor="#07111B"
|
||||
headerType="NONE"
|
||||
>
|
||||
|
||||
@@ -96,7 +96,7 @@ const PlaybackAiStatus = ({ route }) => {
|
||||
|
||||
return (
|
||||
<Page
|
||||
backgroundImg={background.playbackBG2}
|
||||
backgroundImg={background.playbackMobile}
|
||||
backgroundColor="#07111B"
|
||||
headerType="NONE"
|
||||
>
|
||||
|
||||
@@ -91,7 +91,7 @@ const PlaybackAiStatus = ({ route }) => {
|
||||
|
||||
return (
|
||||
<Page
|
||||
backgroundImg={background.playbackBG2}
|
||||
backgroundImg={background.playbackWeb}
|
||||
backgroundColor="#07111B"
|
||||
headerType="NONE"
|
||||
>
|
||||
|
||||
@@ -27,14 +27,13 @@ const PlaybackGuide = ({ navigation }) => {
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<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 style={styles.sectionTitle}>
|
||||
Introduction : Tu Es l'Artiste ET le Performeur !
|
||||
</Text>
|
||||
<Text style={styles.sectionTitle}>Introduction</Text>
|
||||
<Text style={styles.sectionTitle}>Tu es l'artiste et l’interprête!</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 !
|
||||
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
|
||||
@@ -53,9 +52,9 @@ const PlaybackGuide = ({ navigation }) => {
|
||||
transparaître dans chaque mouvement de tes lèvres.
|
||||
</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}>
|
||||
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 style={styles.text}>
|
||||
@@ -69,12 +68,12 @@ const PlaybackGuide = ({ navigation }) => {
|
||||
sur-articulés ?{'\n'}• Où sont tes respirations naturelles dans l'enregistrement ?
|
||||
</Text>
|
||||
<Text style={styles.text}>
|
||||
<Text style={styles.bold}>Écoutes 11-15 : La Transformation</Text> Maintenant,
|
||||
détache-toi du "toi qui a enregistré" pour devenir le "toi qui performe". C'est là que
|
||||
la magie opère !
|
||||
<Text style={styles.bold}>Écoutes 11-12 : La Transformation</Text> Maintenant,
|
||||
détache-toi du "toi qui a enregistré" pour devenir le "toi qui interprête". C'est là
|
||||
que la magie opère !
|
||||
</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.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
|
||||
@@ -107,8 +106,9 @@ const PlaybackGuide = ({ navigation }) => {
|
||||
|
||||
<Text style={styles.subTitle}>Les Angles Qui Racontent Ton Histoire</Text>
|
||||
<Text style={styles.text}>
|
||||
<Text style={styles.bold}>L'Angle "Confession" (Très Proche)</Text> Pour les parties
|
||||
intimes de ta chanson. Le spectateur doit sentir que tu lui confies un secret.
|
||||
<Text style={styles.bold}>L'Angle "Confession" (Très Proche)</Text> Pour une
|
||||
interprêtation intimes de ta chanson. Le spectateur doit sentir que tu lui confies un
|
||||
secret.
|
||||
</Text>
|
||||
<Text style={styles.text}>
|
||||
<Text style={styles.bold}>L'Angle "Concert" (Plan Large)</Text> Montre l'artiste
|
||||
@@ -125,9 +125,10 @@ const PlaybackGuide = ({ navigation }) => {
|
||||
</Text>
|
||||
<Text style={styles.text}>C'est encore plus puissant quand c'est TA chanson !</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
|
||||
main
|
||||
{'\n'}• Montre les accords que TU as choisis
|
||||
Si tu as composé avec un instrument :{'\n'}• Trouve l’instrument de ta chanson et mime
|
||||
comme si tu savait en jouer.
|
||||
{'\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
|
||||
</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.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.
|
||||
</Text>
|
||||
<Text style={styles.text}>
|
||||
@@ -174,10 +175,12 @@ const PlaybackGuide = ({ navigation }) => {
|
||||
<Text style={styles.subTitle}>Suggestions Créatives Uniques :</Text>
|
||||
<Text style={styles.text}>
|
||||
• 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'}• 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 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 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'}• Ils voient ta personnalité
|
||||
{'\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 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.text}>
|
||||
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
|
||||
unique !
|
||||
la performance visuelle de TA chanson. C'est ta première mondiale, ton moment unique !
|
||||
</Text>
|
||||
<Text style={[styles.text, styles.bold]}>
|
||||
Elle ne doit pas être parfaite, elle doit être authentique!
|
||||
</Text>
|
||||
<Text style={styles.text}>
|
||||
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.
|
||||
</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 style={styles.text}>
|
||||
#MonPlaybackMaChanson #CréateurPerformeur #DoubleArtiste
|
||||
<Text style={[styles.text, styles.bold]}>
|
||||
#MonPlaybackMaChanson #Playbacker-performer #Artisteletemps d’une chanson
|
||||
</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
|
||||
tu verras le début de ton parcours d'artiste. C'est de l'or pur !
|
||||
</Text>
|
||||
@@ -302,7 +307,9 @@ const PlaybackGuide = ({ navigation }) => {
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
marginHorizontal: Platform.OS === 'web' ? gutters : 0,
|
||||
width: '100%',
|
||||
maxWidth: 900,
|
||||
alignSelf: 'center',
|
||||
marginBottom: gutters,
|
||||
borderRadius: 20,
|
||||
overflow: 'hidden',
|
||||
@@ -314,6 +321,7 @@ const styles = StyleSheet.create({
|
||||
scrollContent: {
|
||||
padding: gutters,
|
||||
paddingBottom: gutters * 3,
|
||||
alignItems: 'center',
|
||||
},
|
||||
title: {
|
||||
fontFamily: FONT_FAMILY.InterBold,
|
||||
@@ -328,6 +336,7 @@ const styles = StyleSheet.create({
|
||||
color: Palette.primary,
|
||||
marginBottom: 10,
|
||||
marginTop: 10,
|
||||
textAlign: 'center',
|
||||
},
|
||||
chapterTitle: {
|
||||
fontFamily: FONT_FAMILY.InterBold,
|
||||
@@ -343,6 +352,7 @@ const styles = StyleSheet.create({
|
||||
color: Palette.white,
|
||||
marginBottom: 8,
|
||||
marginTop: 15,
|
||||
textAlign: 'center',
|
||||
},
|
||||
text: {
|
||||
fontFamily: FONT_FAMILY.InterRegular,
|
||||
@@ -350,10 +360,14 @@ const styles = StyleSheet.create({
|
||||
color: Palette.white,
|
||||
marginBottom: 10,
|
||||
lineHeight: 24,
|
||||
textAlign: 'center',
|
||||
},
|
||||
bold: {
|
||||
fontFamily: FONT_FAMILY.InterBold,
|
||||
},
|
||||
italic: {
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
})
|
||||
|
||||
export default PlaybackGuide
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { useFocusEffect } from '@react-navigation/native'
|
||||
import { useFocusEffect, useIsFocused } from '@react-navigation/native'
|
||||
import { CameraView, useCameraPermissions } from 'expo-camera'
|
||||
import { activateKeepAwakeAsync, deactivateKeepAwake } from 'expo-keep-awake'
|
||||
import * as FileSystem from 'expo-file-system'
|
||||
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 Svg, { Circle } from 'react-native-svg'
|
||||
import alert from '../../components/Alert'
|
||||
@@ -25,6 +32,7 @@ import CreateLyricsHeader from '../Writing/components/CreateLyricsHeader'
|
||||
const TIME_BEFORE_INCREMENT_MS = 20000 // 20s
|
||||
const COUNTDOWN_SECONDS = 10
|
||||
const CAMERA_STARTUP_DELAY_MS = 250
|
||||
const CAMERA_READY_TIMEOUT_MS = 8000
|
||||
const LOG_PREFIX = '[RecordPlayback]'
|
||||
const KEEP_AWAKE_TAG = 'record-playback'
|
||||
const log =
|
||||
@@ -39,6 +47,7 @@ const CAMERA_FACING_OPTIONS = [
|
||||
|
||||
const RecordPlayback = ({ route }) => {
|
||||
const { top, bottom } = useSafeAreaInsets()
|
||||
const isFocused = useIsFocused()
|
||||
const { project } = route.params || {}
|
||||
log('Route params received', { hasProject: !!project })
|
||||
const projectId = project?.id
|
||||
@@ -76,6 +85,9 @@ const RecordPlayback = ({ route }) => {
|
||||
const [isRecording, setIsRecording] = useState(false)
|
||||
const [showProgress, setShowProgress] = useState(false)
|
||||
const [cameraFacing, setCameraFacing] = useState('front')
|
||||
const [cameraReady, setCameraReady] = useState(false)
|
||||
const [cameraMountError, setCameraMountError] = useState(null)
|
||||
const [cameraMountKey, setCameraMountKey] = useState(0)
|
||||
|
||||
// Musique
|
||||
const songUrl = project?.songUrl || null
|
||||
@@ -771,142 +783,245 @@ const RecordPlayback = ({ route }) => {
|
||||
}
|
||||
|
||||
const permissionsGranted = !!cameraPermission?.granted
|
||||
const cameraCanMount = permissionsGranted && isFocused
|
||||
|
||||
useEffect(() => {
|
||||
log('Camera permission state updated', { granted: 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 (
|
||||
<View style={{ flex: 1 }}>
|
||||
<CameraView ref={cameraRef} style={{ flex: 1 }} facing={cameraFacing} mode="video" mute>
|
||||
<View
|
||||
style={{
|
||||
paddingHorizontal: gutters,
|
||||
paddingTop: top,
|
||||
flex: 1,
|
||||
paddingBottom: gutters * 2,
|
||||
}}
|
||||
>
|
||||
<MusicLandHeader progress={9} onPressBack={handleBackPress} />
|
||||
<View style={{ flex: 1, backgroundColor: Palette.black }}>
|
||||
{cameraCanMount && (
|
||||
<CameraView
|
||||
key={`${cameraFacing}-${cameraMountKey}`}
|
||||
ref={cameraRef}
|
||||
style={StyleSheet.absoluteFillObject}
|
||||
facing={cameraFacing}
|
||||
mode="video"
|
||||
mute
|
||||
active={isFocused}
|
||||
onCameraReady={handleCameraReady}
|
||||
onMountError={handleCameraMountError}
|
||||
/>
|
||||
)}
|
||||
|
||||
<View style={{ marginTop: 12, alignItems: 'flex-end' }}>
|
||||
<CameraFacingSelector
|
||||
value={cameraFacing}
|
||||
onChange={setCameraFacing}
|
||||
disabled={!permissionsGranted || isPreparing || isRecording}
|
||||
/>
|
||||
</View>
|
||||
<View
|
||||
style={{
|
||||
paddingHorizontal: gutters,
|
||||
paddingTop: top,
|
||||
flex: 1,
|
||||
paddingBottom: gutters * 2,
|
||||
}}
|
||||
>
|
||||
<MusicLandHeader progress={9} onPressBack={handleBackPress} />
|
||||
|
||||
<View style={{ flex: 1, marginTop: 11 }}>
|
||||
{/* Overlay de compte à rebours : on affiche 5→1 pour éviter l'effet visuel à 1 */}
|
||||
{isPreparing && countdown >= 1 && !showProgress && (
|
||||
<View
|
||||
<View style={{ marginTop: 12, alignItems: 'flex-end' }}>
|
||||
<CameraFacingSelector
|
||||
value={cameraFacing}
|
||||
onChange={handleCameraFacingChange}
|
||||
disabled={!cameraReady || isPreparing || isRecording}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={{ flex: 1, marginTop: 11 }}>
|
||||
{/* Overlay de compte à rebours : on affiche 5→1 pour éviter l'effet visuel à 1 */}
|
||||
{isPreparing && countdown >= 1 && !showProgress && (
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 72,
|
||||
color: Palette.white,
|
||||
fontFamily: FONT_FAMILY.HelveticaNeueBold,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 72,
|
||||
color: Palette.white,
|
||||
fontFamily: FONT_FAMILY.HelveticaNeueBold,
|
||||
}}
|
||||
>
|
||||
{countdown}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Permission prompt */}
|
||||
{!permissionsGranted && (
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
padding: gutters,
|
||||
}}
|
||||
>
|
||||
<GradientButton
|
||||
title="Autoriser la caméra"
|
||||
onPress={async () => {
|
||||
try {
|
||||
if (!cameraPermission?.granted) await requestCameraPermission()
|
||||
} catch (_) {}
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Start button */}
|
||||
{permissionsGranted && !isPreparing && !isRecording && (
|
||||
<GradientButton
|
||||
title="Lancer ma musique"
|
||||
containerStyle={{ width: '80%', alignSelf: 'center' }}
|
||||
disabled={!songUrl}
|
||||
onPress={startCountdownThenRecord}
|
||||
/>
|
||||
{countdown}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Progress circulaire + restart */}
|
||||
{permissionsGranted && (isRecording || showProgress || isPreparing) && (
|
||||
{/* Permission prompt */}
|
||||
{!permissionsGranted && (
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: gutters,
|
||||
bottom: 0,
|
||||
padding: gutters,
|
||||
}}
|
||||
>
|
||||
<GradientButton
|
||||
title="Autoriser la caméra"
|
||||
onPress={async () => {
|
||||
try {
|
||||
if (!cameraPermission?.granted) await requestCameraPermission()
|
||||
} catch (_) {}
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
|
||||
{/* Start button */}
|
||||
{cameraReady && !isPreparing && !isRecording && (
|
||||
<GradientButton
|
||||
title="Lancer ma musique"
|
||||
containerStyle={{ width: '80%', alignSelf: 'center' }}
|
||||
disabled={!songUrl}
|
||||
onPress={startCountdownThenRecord}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Progress circulaire + restart */}
|
||||
{cameraReady && (isRecording || showProgress || isPreparing) && (
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: gutters,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 120,
|
||||
height: 120,
|
||||
borderRadius: 120,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<View
|
||||
<ProgressRing
|
||||
size={120}
|
||||
strokeWidth={8}
|
||||
progress={durationMs ? Math.min(1, positionMs / durationMs) : 0}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.9}
|
||||
onPress={handleRestartRecording}
|
||||
style={{
|
||||
width: 120,
|
||||
height: 120,
|
||||
borderRadius: 120,
|
||||
position: 'absolute',
|
||||
width: 70,
|
||||
height: 70,
|
||||
borderRadius: 80,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: Palette.white,
|
||||
shadowColor: '#000000',
|
||||
shadowOpacity: 0.12,
|
||||
shadowRadius: 10,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
elevation: 4,
|
||||
}}
|
||||
>
|
||||
<ProgressRing
|
||||
size={120}
|
||||
strokeWidth={8}
|
||||
progress={durationMs ? Math.min(1, positionMs / durationMs) : 0}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.9}
|
||||
onPress={handleRestartRecording}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: 70,
|
||||
height: 70,
|
||||
borderRadius: 80,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: Palette.white,
|
||||
shadowColor: '#000000',
|
||||
shadowOpacity: 0.12,
|
||||
shadowRadius: 10,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
elevation: 4,
|
||||
}}
|
||||
>
|
||||
<RestartSpinnerIcon />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<RestartSpinnerIcon />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</CameraView>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{/* CreateLyricsHeader overlay outside of CameraView */}
|
||||
<View
|
||||
pointerEvents="box-none"
|
||||
|
||||
@@ -8,8 +8,10 @@ import useDataFromRef from '../../hooks/useDataFromRef'
|
||||
import usePlayer from '../../hooks/usePlayer'
|
||||
import PlaybackItem from './components/PlaybackItem'
|
||||
import PlaybackCharts from './components/PlaybackCharts'
|
||||
import PlaybackTypeTabs from './components/PlaybackTypeTabs'
|
||||
import { Palette } from '../../styles'
|
||||
import { FONT_FAMILY } from '../../styles/Fonts'
|
||||
import { getPlaybackType, matchesPlaybackType, PLAYBACK_TYPE } from '../../utils/playbackType'
|
||||
|
||||
const Playbacks = () => {
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
@@ -21,25 +23,36 @@ const Playbacks = () => {
|
||||
const initialFocusProjectId = route?.params?.projectId || route?.params?.focusId || null
|
||||
const [focusProjectId, setFocusProjectId] = useState(initialFocusProjectId)
|
||||
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),
|
||||
simpleRef: false,
|
||||
listener: false,
|
||||
usePagination: true,
|
||||
batchSize: 6,
|
||||
})
|
||||
const filteredPlaybacks = useMemo(
|
||||
() => rawPlaybacks.filter((item) => matchesPlaybackType(item, playbackType)),
|
||||
[playbackType, rawPlaybacks]
|
||||
)
|
||||
|
||||
const focusIndex = useMemo(() => {
|
||||
if (!focusProjectId) return -1
|
||||
return rawPlaybacks.findIndex((p) => p?.id === focusProjectId)
|
||||
}, [focusProjectId, rawPlaybacks])
|
||||
return filteredPlaybacks.findIndex((p) => p?.id === focusProjectId)
|
||||
}, [filteredPlaybacks, focusProjectId])
|
||||
|
||||
const playbacks = useMemo(() => {
|
||||
if (focusIndex < 0) return rawPlaybacks
|
||||
const target = rawPlaybacks[focusIndex]
|
||||
const before = rawPlaybacks.slice(0, focusIndex)
|
||||
const after = rawPlaybacks.slice(focusIndex + 1)
|
||||
if (focusIndex < 0) return filteredPlaybacks
|
||||
const target = filteredPlaybacks[focusIndex]
|
||||
const before = filteredPlaybacks.slice(0, focusIndex)
|
||||
const after = filteredPlaybacks.slice(focusIndex + 1)
|
||||
return [target, ...after, ...before]
|
||||
}, [focusIndex, rawPlaybacks])
|
||||
}, [filteredPlaybacks, focusIndex])
|
||||
|
||||
const onSnap = useCallback(
|
||||
(index) => {
|
||||
@@ -63,6 +76,14 @@ const Playbacks = () => {
|
||||
}
|
||||
}, [focusProjectId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!focusProjectId) return
|
||||
const focusedPlayback = rawPlaybacks.find((item) => item?.id === focusProjectId)
|
||||
if (focusedPlayback) {
|
||||
setPlaybackType(getPlaybackType(focusedPlayback))
|
||||
}
|
||||
}, [focusProjectId, rawPlaybacks])
|
||||
|
||||
useEffect(() => {
|
||||
if (loadMore && triedLoadMoreRef.current < 6) {
|
||||
triedLoadMoreRef.current += 1
|
||||
@@ -70,6 +91,12 @@ const Playbacks = () => {
|
||||
}
|
||||
}, [loadMore, playbacks])
|
||||
|
||||
useEffect(() => {
|
||||
if (playbacks.length === 0 && hasMore && !loading) {
|
||||
loadMore?.()
|
||||
}
|
||||
}, [hasMore, loadMore, loading, playbacks.length])
|
||||
|
||||
useEffect(() => {
|
||||
if (!focusProjectId) return
|
||||
if (focusIndex >= 0 && !hasAppliedFocusRef.current) {
|
||||
@@ -107,6 +134,12 @@ const Playbacks = () => {
|
||||
|
||||
const showFeed = viewMode === 'feed'
|
||||
|
||||
const handlePlaybackTypeChange = useCallback((nextType) => {
|
||||
setFocusProjectId(null)
|
||||
setPlaybackType(nextType)
|
||||
setActiveIndex(0)
|
||||
}, [])
|
||||
|
||||
const openFeedForPlayback = useCallback((project) => {
|
||||
const nextId =
|
||||
project && typeof project === 'object'
|
||||
@@ -115,17 +148,27 @@ const Playbacks = () => {
|
||||
if (!nextId) {
|
||||
return
|
||||
}
|
||||
if (project && typeof project === 'object') {
|
||||
setPlaybackType(getPlaybackType(project))
|
||||
}
|
||||
setFocusProjectId(nextId)
|
||||
setViewMode('feed')
|
||||
}, [])
|
||||
|
||||
if (!showFeed) {
|
||||
return <PlaybackCharts onPlaybackPress={openFeedForPlayback} />
|
||||
return (
|
||||
<PlaybackCharts
|
||||
onPlaybackPress={openFeedForPlayback}
|
||||
playbackType={playbackType}
|
||||
onPlaybackTypeChange={handlePlaybackTypeChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: 'black' }}>
|
||||
<Carousel
|
||||
key={playbackType}
|
||||
data={playbacks}
|
||||
vertical
|
||||
height={responsiveHeight(100)}
|
||||
@@ -141,14 +184,28 @@ const Playbacks = () => {
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Pressable
|
||||
style={styles.backToCharts}
|
||||
hitSlop={12}
|
||||
onPress={() => setViewMode('charts')}
|
||||
accessibilityLabel="Revenir au classement des playbacks"
|
||||
>
|
||||
<Text style={styles.backToChartsText}>Classement</Text>
|
||||
</Pressable>
|
||||
{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
|
||||
style={styles.backToCharts}
|
||||
hitSlop={12}
|
||||
onPress={() => setViewMode('charts')}
|
||||
accessibilityLabel="Revenir au classement des playbacks"
|
||||
>
|
||||
<Text style={styles.backToChartsText}>Classement</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -156,10 +213,18 @@ const Playbacks = () => {
|
||||
export default Playbacks
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
backToCharts: {
|
||||
feedHeader: {
|
||||
position: 'absolute',
|
||||
top: 32,
|
||||
right: 20,
|
||||
left: 16,
|
||||
right: 16,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 8,
|
||||
zIndex: 20,
|
||||
},
|
||||
backToCharts: {
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: 12,
|
||||
@@ -171,4 +236,16 @@ const styles = StyleSheet.create({
|
||||
fontSize: 12,
|
||||
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',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -18,6 +18,8 @@ import { Palette } from '../../styles'
|
||||
import { FONT_FAMILY } from '../../styles/Fonts'
|
||||
import PlaybackItem from './components/PlaybackItem.web'
|
||||
import PlaybackCharts from './components/PlaybackCharts'
|
||||
import PlaybackTypeTabs from './components/PlaybackTypeTabs'
|
||||
import { getPlaybackType, matchesPlaybackType, PLAYBACK_TYPE } from '../../utils/playbackType'
|
||||
|
||||
const Playbacks = () => {
|
||||
const { height: windowHeight } = useWindowDimensions()
|
||||
@@ -28,6 +30,7 @@ const Playbacks = () => {
|
||||
const initialFocusProjectId = route?.params?.projectId || route?.params?.focusId || null
|
||||
const [focusProjectId, setFocusProjectId] = useState(initialFocusProjectId)
|
||||
const [viewMode, setViewMode] = useState(initialFocusProjectId ? 'feed' : 'charts')
|
||||
const [playbackType, setPlaybackType] = useState(PLAYBACK_TYPE.LIVE)
|
||||
const userCache = useRef(new Map())
|
||||
const { getUserByUid } = useUser() || {}
|
||||
const isFocused = useIsFocused()
|
||||
@@ -43,18 +46,23 @@ const Playbacks = () => {
|
||||
usePagination: true,
|
||||
batchSize: 6,
|
||||
})
|
||||
const filteredPlaybacks = useMemo(
|
||||
() => rawPlaybacks.filter((item) => matchesPlaybackType(item, playbackType)),
|
||||
[playbackType, rawPlaybacks]
|
||||
)
|
||||
|
||||
const focusIndex = useMemo(() => {
|
||||
if (!focusProjectId) return -1
|
||||
return rawPlaybacks.findIndex((p) => p?.id === focusProjectId)
|
||||
}, [focusProjectId, rawPlaybacks])
|
||||
return filteredPlaybacks.findIndex((p) => p?.id === focusProjectId)
|
||||
}, [filteredPlaybacks, focusProjectId])
|
||||
|
||||
const playbacks = useMemo(() => {
|
||||
if (focusIndex < 0) return rawPlaybacks
|
||||
const target = rawPlaybacks[focusIndex]
|
||||
const before = rawPlaybacks.slice(0, focusIndex)
|
||||
const after = rawPlaybacks.slice(focusIndex + 1)
|
||||
if (focusIndex < 0) return filteredPlaybacks
|
||||
const target = filteredPlaybacks[focusIndex]
|
||||
const before = filteredPlaybacks.slice(0, focusIndex)
|
||||
const after = filteredPlaybacks.slice(focusIndex + 1)
|
||||
return [target, ...after, ...before]
|
||||
}, [focusIndex, rawPlaybacks])
|
||||
}, [filteredPlaybacks, focusIndex])
|
||||
|
||||
const activePlayback = playbacks?.[activeIndex] || null
|
||||
const activeVideoUrl = activePlayback?.playbackUrl || null
|
||||
@@ -187,6 +195,14 @@ const Playbacks = () => {
|
||||
focusLoadAttemptsRef.current = 0
|
||||
}, [focusProjectId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!focusProjectId) return
|
||||
const focusedPlayback = rawPlaybacks.find((item) => item?.id === focusProjectId)
|
||||
if (focusedPlayback) {
|
||||
setPlaybackType(getPlaybackType(focusedPlayback))
|
||||
}
|
||||
}, [focusProjectId, rawPlaybacks])
|
||||
|
||||
useEffect(() => {
|
||||
if (!focusProjectId) return
|
||||
if (focusIndex >= 0 && !hasAppliedFocusRef.current) {
|
||||
@@ -206,6 +222,12 @@ const Playbacks = () => {
|
||||
}
|
||||
}, [focusIndex, focusProjectId, loadMore, playbacks])
|
||||
|
||||
useEffect(() => {
|
||||
if (playbacks.length === 0 && hasMore && !loading) {
|
||||
loadMore?.()
|
||||
}
|
||||
}, [hasMore, loadMore, loading, playbacks.length])
|
||||
|
||||
useEffect(() => {
|
||||
const nextFocusId = route?.params?.projectId || route?.params?.focusId || null
|
||||
setFocusProjectId(nextFocusId)
|
||||
@@ -334,6 +356,13 @@ const Playbacks = () => {
|
||||
|
||||
const showFeed = viewMode === 'feed'
|
||||
|
||||
const handlePlaybackTypeChange = useCallback((nextType) => {
|
||||
setFocusProjectId(null)
|
||||
setPlaybackType(nextType)
|
||||
lastActiveIndexRef.current = 0
|
||||
setActiveIndex(0)
|
||||
}, [])
|
||||
|
||||
const openFeedForPlayback = useCallback((project) => {
|
||||
const nextId =
|
||||
project && typeof project === 'object'
|
||||
@@ -342,12 +371,21 @@ const Playbacks = () => {
|
||||
if (!nextId) {
|
||||
return
|
||||
}
|
||||
if (project && typeof project === 'object') {
|
||||
setPlaybackType(getPlaybackType(project))
|
||||
}
|
||||
setFocusProjectId(nextId)
|
||||
setViewMode('feed')
|
||||
}, [])
|
||||
|
||||
if (!showFeed) {
|
||||
return <PlaybackCharts onPlaybackPress={openFeedForPlayback} />
|
||||
return (
|
||||
<PlaybackCharts
|
||||
onPlaybackPress={openFeedForPlayback}
|
||||
playbackType={playbackType}
|
||||
onPlaybackTypeChange={handlePlaybackTypeChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -377,6 +415,7 @@ const Playbacks = () => {
|
||||
</View>
|
||||
) : null}
|
||||
<FlatList
|
||||
key={playbackType}
|
||||
ref={listRef}
|
||||
data={playbacks}
|
||||
keyExtractor={(item, index) => String(item?.id || index)}
|
||||
@@ -401,6 +440,13 @@ const Playbacks = () => {
|
||||
onScroll={onScroll}
|
||||
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 && (
|
||||
<View style={styles.indicatorContainer} pointerEvents="box-none">
|
||||
<Pressable
|
||||
@@ -452,15 +498,22 @@ const Playbacks = () => {
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
<Pressable
|
||||
style={styles.backToCharts}
|
||||
hitSlop={12}
|
||||
onPress={() => setViewMode('charts')}
|
||||
accessibilityLabel="Revenir au classement des playbacks"
|
||||
>
|
||||
<Image source={icons.sort} resizeMode="contain" style={styles.backToChartsIcon} />
|
||||
<Text style={styles.backToChartsText}>Retour au classement</Text>
|
||||
</Pressable>
|
||||
<View style={styles.feedHeader}>
|
||||
<PlaybackTypeTabs
|
||||
value={playbackType}
|
||||
onChange={handlePlaybackTypeChange}
|
||||
compact
|
||||
/>
|
||||
<Pressable
|
||||
style={styles.backToCharts}
|
||||
hitSlop={12}
|
||||
onPress={() => setViewMode('charts')}
|
||||
accessibilityLabel="Revenir au classement des playbacks"
|
||||
>
|
||||
<Image source={icons.sort} resizeMode="contain" style={styles.backToChartsIcon} />
|
||||
<Text style={styles.backToChartsText}>Retour au classement</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
// </Page>
|
||||
)
|
||||
@@ -487,6 +540,17 @@ const styles = StyleSheet.create({
|
||||
...StyleSheet.absoluteFillObject,
|
||||
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: {
|
||||
position: 'absolute',
|
||||
top: 32,
|
||||
@@ -571,9 +635,6 @@ const styles = StyleSheet.create({
|
||||
textAlign: 'center',
|
||||
},
|
||||
backToCharts: {
|
||||
position: 'absolute',
|
||||
top: 28,
|
||||
right: 24,
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 14,
|
||||
borderRadius: 999,
|
||||
@@ -595,4 +656,16 @@ const styles = StyleSheet.create({
|
||||
fontSize: 13,
|
||||
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 { BlurView } from 'expo-blur'
|
||||
import { responsiveHeight } from 'react-native-responsive-dimensions'
|
||||
import { background } from '../../../assets'
|
||||
import { background, img } from '../../../assets'
|
||||
import BorderGradientButton from '../../../components/BorderGradientButton'
|
||||
import MobileCoinBadge from '../../../components/MobileCoinBadge'
|
||||
import SearchBar from '../../../components/SearchBar'
|
||||
@@ -17,7 +17,9 @@ import CreateLyricsHeader from '../../Writing/components/CreateLyricsHeader'
|
||||
import SongCard from '../../HitParade/components/SongCard'
|
||||
import { Palette, Style } from '../../../styles'
|
||||
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'])
|
||||
|
||||
@@ -37,7 +39,11 @@ const chunkArray = (items = [], size = 10) => {
|
||||
return chunks
|
||||
}
|
||||
|
||||
const PlaybackCharts = ({ onPlaybackPress }) => {
|
||||
const PlaybackCharts = ({
|
||||
onPlaybackPress,
|
||||
playbackType = PLAYBACK_TYPE.LIVE,
|
||||
onPlaybackTypeChange,
|
||||
}) => {
|
||||
const isWeb = Platform.OS === 'web'
|
||||
const [selectedCategory, setSelectedCategory] = useState('Playbacks')
|
||||
const {
|
||||
@@ -67,19 +73,25 @@ const PlaybackCharts = ({ onPlaybackPress }) => {
|
||||
condition: true,
|
||||
})
|
||||
|
||||
const playbacksList = useMemo(
|
||||
() => (Array.isArray(allPlaybacks) ? allPlaybacks : []),
|
||||
[allPlaybacks]
|
||||
)
|
||||
const playbacksList = useMemo(() => {
|
||||
const items = Array.isArray(allPlaybacks) ? allPlaybacks : []
|
||||
return items.filter((item) => matchesPlaybackType(item, playbackType))
|
||||
}, [allPlaybacks, playbackType])
|
||||
|
||||
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(
|
||||
() => (Array.isArray(allTimePlaybacks) ? allTimePlaybacks : []),
|
||||
[allTimePlaybacks]
|
||||
() => {
|
||||
const items = Array.isArray(allTimePlaybacks) ? allTimePlaybacks : []
|
||||
return items.filter((item) => matchesPlaybackType(item, playbackType))
|
||||
},
|
||||
[allTimePlaybacks, playbackType]
|
||||
)
|
||||
|
||||
const {
|
||||
@@ -104,7 +116,10 @@ const PlaybackCharts = ({ onPlaybackPress }) => {
|
||||
)
|
||||
|
||||
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 musicsLoading = searchLoading
|
||||
@@ -252,8 +267,18 @@ const PlaybackCharts = ({ onPlaybackPress }) => {
|
||||
[onPlaybackPress]
|
||||
)
|
||||
|
||||
const heroTitle = isWeb ? 'Playbacks coups de coeur' : 'Classements des playbacks'
|
||||
const heroSubtitle = 'Découvre les playbacks les plus écoutés et lance la lecture verticale.'
|
||||
const playbackTypeLabel = playbackType === PLAYBACK_TYPE.AI ? 'Playbacks IA' : 'Playbacks live'
|
||||
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 = () => (
|
||||
<View style={{ gap: 11, maxWidth: 800, width: '100%', alignSelf: 'center' }}>
|
||||
@@ -289,6 +314,7 @@ const PlaybackCharts = ({ onPlaybackPress }) => {
|
||||
data={playbacksList}
|
||||
keyExtractor={(item) => item.id}
|
||||
refreshing={allPlaybacksLoading}
|
||||
ListEmptyComponent={renderEmptyState}
|
||||
renderItem={({ item }) => (
|
||||
<SongCard
|
||||
rank={null}
|
||||
@@ -359,6 +385,7 @@ const PlaybackCharts = ({ onPlaybackPress }) => {
|
||||
onPress={() => handlePlaybackSelected(item)}
|
||||
/>
|
||||
))}
|
||||
{playbacksList.length === 0 ? renderEmptyState() : null}
|
||||
</View>
|
||||
)
|
||||
|
||||
@@ -435,7 +462,7 @@ const PlaybackCharts = ({ onPlaybackPress }) => {
|
||||
fontFamily: FONT_FAMILY.HelveticaNeueBold,
|
||||
}}
|
||||
>
|
||||
Playbacks
|
||||
{playbackTypeLabel}
|
||||
</Text>
|
||||
</BlurView>
|
||||
<ScrollView
|
||||
@@ -544,7 +571,7 @@ const PlaybackCharts = ({ onPlaybackPress }) => {
|
||||
|
||||
return (
|
||||
<Page
|
||||
backgroundImg={isWeb ? background.playbackBG2 : background.playbackBG}
|
||||
backgroundImg={background.playbackMainBackground}
|
||||
headerType="NONE"
|
||||
width="100%"
|
||||
maxWidth={1200}
|
||||
@@ -567,7 +594,13 @@ const PlaybackCharts = ({ onPlaybackPress }) => {
|
||||
</View>
|
||||
) : null}
|
||||
<View style={{ gap: 12, flex: 1 }}>
|
||||
<ClubCard />
|
||||
<SpaceLogoCard
|
||||
source={img.musiclandPlayback}
|
||||
accessibilityLabel="Playback MusicLand"
|
||||
imageStyle={styles.playbackLogoImage}
|
||||
/>
|
||||
|
||||
<PlaybackTypeTabs value={playbackType} onChange={onPlaybackTypeChange} />
|
||||
|
||||
{isWeb && (
|
||||
<View
|
||||
@@ -660,3 +693,20 @@ const PlaybackCharts = ({ onPlaybackPress }) => {
|
||||
}
|
||||
|
||||
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',
|
||||
},
|
||||
})
|
||||
@@ -13,7 +13,7 @@ import firebase, { getFunctionsClient, projectsRef, serverTimestamp } from '../.
|
||||
import { uploadFileToFirebase } from '../../helpers/uploadToFirebase'
|
||||
import Page from '../../layouts/Page'
|
||||
import { Routes } from '../../navigation'
|
||||
import { goBack, navigate } from '../../navigation/NavigationService'
|
||||
import { navigate } from '../../navigation/NavigationService'
|
||||
import { useUserData, useUser } from '../../providers/UserDataProvider'
|
||||
import { useStripe } from '../../providers/StripeProvider'
|
||||
import { Palette } from '../../styles'
|
||||
@@ -391,6 +391,15 @@ const PlaybackDownload = ({ route }) => {
|
||||
handleDownloadUri()
|
||||
}, [downloadPaymentPending, handleDownloadUri, hasPaidPlaybackDownload, isDownloading])
|
||||
|
||||
const handleGoHome = useCallback(() => {
|
||||
navigate(Routes.BottomTab, {
|
||||
screen: Routes.HomeStack,
|
||||
params: {
|
||||
screen: Routes.Home,
|
||||
},
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handlePublish = async () => {
|
||||
if (!hasAcceptedPublication) return
|
||||
|
||||
@@ -402,6 +411,7 @@ const PlaybackDownload = ({ route }) => {
|
||||
await projectsRef.doc(projectForDownload.id).set(
|
||||
{
|
||||
playbackUrl: url,
|
||||
playbackProvider: 'live',
|
||||
updatedAt: serverTimestamp(),
|
||||
},
|
||||
{ merge: true }
|
||||
@@ -431,7 +441,7 @@ const PlaybackDownload = ({ route }) => {
|
||||
backgroundColor="#07111B"
|
||||
headerType="NONE"
|
||||
>
|
||||
<MusicLandHeader onPressBack={goBack} progress={50} />
|
||||
<MusicLandHeader onPressBack={handleGoHome} progress={50} />
|
||||
<ScrollView contentContainerStyle={styles.container} showsVerticalScrollIndicator={false}>
|
||||
<CreateLyricsHeader title={'Publier ton playback'} />
|
||||
|
||||
@@ -494,6 +504,12 @@ const PlaybackDownload = ({ route }) => {
|
||||
loadingText="Publication en cours..."
|
||||
containerStyle={styles.continueButton}
|
||||
/>
|
||||
<BorderGradientButton
|
||||
title="Retour à l'accueil"
|
||||
onPress={handleGoHome}
|
||||
disabled={isPublishing || isDownloading}
|
||||
containerStyle={styles.homeButton}
|
||||
/>
|
||||
</ScrollView>
|
||||
</Page>
|
||||
</>
|
||||
@@ -562,4 +578,8 @@ const styles = StyleSheet.create({
|
||||
continueButton: {
|
||||
marginTop: 10,
|
||||
},
|
||||
homeButton: {
|
||||
marginTop: 2,
|
||||
marginBottom: 18,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import * as AppleAuthentication from 'expo-apple-authentication'
|
||||
import DateTimePicker from '@react-native-community/datetimepicker'
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import { useGlobal } from 'reactn'
|
||||
import {
|
||||
@@ -19,6 +18,7 @@ import { background } from '../assets'
|
||||
import BorderGradientButton from '../components/BorderGradientButton'
|
||||
import { Input } from '../components/Input'
|
||||
import ItemContainer from '../components/ItemContainer/ItemContainer'
|
||||
import NativeDateModal from '../components/modal/NativeDateModal'
|
||||
import WebDateModal from '../components/modal/WebDateModal'
|
||||
import firebase from '../config/firebase'
|
||||
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 = () => (
|
||||
<View style={[styles.formContent, isCompactWeb ? styles.compactFormContent : null]}>
|
||||
<Text style={[styles.title, isCompactWeb ? styles.compactTitle : null]}>
|
||||
@@ -200,17 +193,13 @@ const Register = ({ navigation }) => {
|
||||
/>
|
||||
</View>
|
||||
</Pressable>
|
||||
{showDatePicker && (
|
||||
<DateTimePicker
|
||||
testID="dateTimePicker"
|
||||
value={birthDate || new Date()}
|
||||
mode="date"
|
||||
is24Hour={true}
|
||||
display="default"
|
||||
onChange={onDateChange}
|
||||
maximumDate={new Date()}
|
||||
/>
|
||||
)}
|
||||
<NativeDateModal
|
||||
visible={showDatePicker}
|
||||
onClose={() => setShowDatePicker(false)}
|
||||
onValidate={setBirthDate}
|
||||
initialDate={birthDate}
|
||||
language={preferredLanguage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
@@ -248,7 +237,7 @@ const Register = ({ navigation }) => {
|
||||
<Page
|
||||
width={isWeb ? '100%' : null}
|
||||
maxWidth={isWeb ? 640 : 1200}
|
||||
backgroundImg={isWeb ? background.loginBgWeb : background.homeBG}
|
||||
backgroundImg={background.loginBgWeb}
|
||||
headerType="NAVIGATION"
|
||||
title="Inscription"
|
||||
onBackPressed={handleBack}
|
||||
|
||||
@@ -55,7 +55,7 @@ const RegistrationChoice = () => {
|
||||
return (
|
||||
<Page
|
||||
width={isWeb ? 720 : null}
|
||||
backgroundImg={isWeb ? background.loginBgWeb : background.homeBG}
|
||||
backgroundImg={background.loginBgWeb}
|
||||
headerType="NONE"
|
||||
title="Choisis ton expérience"
|
||||
>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
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 SwiperFlatList from 'react-native-swiper-flatlist'
|
||||
import { background, icons } from '../../assets'
|
||||
import BackgroundVideo from '../../components/BackgroundVideo'
|
||||
import BorderGradientButton from '../../components/BorderGradientButton'
|
||||
import GradientButton from '../../components/GradientButton'
|
||||
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 ComposeSong = () => {
|
||||
const isFocused = useIsFocused()
|
||||
const scrollRef = useRef(null)
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const [progress, setProgress] = useState(18)
|
||||
@@ -49,7 +47,6 @@ const ComposeSong = () => {
|
||||
updateProjectData,
|
||||
currentUserData,
|
||||
currentUID,
|
||||
videos,
|
||||
} = useUser()
|
||||
|
||||
// Selections state
|
||||
@@ -294,13 +291,7 @@ const ComposeSong = () => {
|
||||
])
|
||||
|
||||
return (
|
||||
<Page
|
||||
backgroundImg={background.studioBG2}
|
||||
backgroundContent={
|
||||
isFocused ? <BackgroundVideo source={videos?.malik} /> : null
|
||||
}
|
||||
headerType="NONE"
|
||||
>
|
||||
<Page backgroundImg={background.studioBG2} headerType="NONE">
|
||||
<MusicLandHeader onPressBack={onPressBack} progress={progress} logo={icons.musicLandStudio} />
|
||||
<View style={{ flex: 1, paddingBottom: gutters, gap: 48 }}>
|
||||
<View
|
||||
|
||||
@@ -30,6 +30,7 @@ const GeneratingSong = () => {
|
||||
const isFocused = useIsFocused()
|
||||
const askedRef = useRef(false)
|
||||
const callingRef = useRef(false)
|
||||
const statusPollInFlightRef = useRef(false)
|
||||
const localStartRef = useRef(null)
|
||||
const errorAlertShownRef = useRef(false)
|
||||
|
||||
@@ -132,6 +133,46 @@ const GeneratingSong = () => {
|
||||
}
|
||||
}, [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(() => {
|
||||
// Use selectedProject.musicConfig only
|
||||
const cfg = selectedProject?.musicConfig || {}
|
||||
@@ -165,8 +206,11 @@ const GeneratingSong = () => {
|
||||
tempo: cfg?.tempo,
|
||||
projectId: selectedProjectId || selectedProject?.id || null,
|
||||
})
|
||||
const taskId = data?.response?.data?.taskId || data?.response?.data?.task_id
|
||||
if (selectedProjectId && taskId) {
|
||||
const taskId = data?.response?.data?.taskId
|
||||
if (!taskId) {
|
||||
throw new Error("Suno n'a pas retourné d'identifiant de génération.")
|
||||
}
|
||||
if (selectedProjectId) {
|
||||
const baseUpdate = {
|
||||
sunoTaskId: taskId,
|
||||
musicStatus: 'GENERATING',
|
||||
|
||||
@@ -122,6 +122,7 @@ const SongReady = () => {
|
||||
coverUrl: null,
|
||||
coverStatus: null,
|
||||
playbackUrl: null,
|
||||
playbackProvider: null,
|
||||
playbackStatus: null,
|
||||
playbackGenerating: null,
|
||||
})
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Dimensions, KeyboardAvoidingView, Platform, View } from 'react-native'
|
||||
import { responsiveHeight } from 'react-native-responsive-dimensions'
|
||||
import { SwiperFlatList } from 'react-native-swiper-flatlist'
|
||||
import { background } from '../../assets'
|
||||
import BackgroundVideo from '../../components/BackgroundVideo'
|
||||
import GradientButton from '../../components/GradientButton'
|
||||
import MusicLandHeader from '../../components/MusicLandHeader'
|
||||
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 CreateLyricsWithAi = () => {
|
||||
const { selectedProject, videos } = useUser()
|
||||
const { selectedProject } = useUser()
|
||||
const scrollRef = useRef(null)
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const [progress, setProgress] = useState(16)
|
||||
@@ -342,15 +341,10 @@ const CreateLyricsWithAi = () => {
|
||||
}, [selectedIndex, parentLayout?.height])
|
||||
|
||||
const isCtaVisible = selectedIndex !== 8
|
||||
const isQuestionStep = selectedIndex < MAX_STEP_INDEX
|
||||
|
||||
return (
|
||||
<Page
|
||||
headerType="NONE"
|
||||
backgroundImg={isQuestionStep ? null : background.writingMobileBackground}
|
||||
backgroundContent={
|
||||
isQuestionStep ? <BackgroundVideo source={videos?.celine} /> : null
|
||||
}
|
||||
backgroundImg={background.writingMobileBackground}
|
||||
>
|
||||
<View
|
||||
onLayout={(event) => {
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
const FAKE_PROGRESS_MAX = 96
|
||||
const PROGRESS_INTERVAL_MS = 250
|
||||
const CUSTOM_SANITIZE_OPTIONS = { autoInjectPreChorus: false }
|
||||
const FUNCTIONS_REGION = 'europe-west1'
|
||||
const FUNCTIONS_REGION = 'us-central1'
|
||||
|
||||
const CreatingLyrics = ({ active, config, selections, onErrorRedirect }) => {
|
||||
const [progress, setProgress] = useState(0)
|
||||
|
||||
@@ -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 { background, icons } from '../../assets'
|
||||
import alert from '../../components/Alert'
|
||||
@@ -31,7 +31,7 @@ import CustomInput from './components/CustomInput'
|
||||
|
||||
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.'
|
||||
const FUNCTIONS_REGION = 'europe-west1'
|
||||
const FUNCTIONS_REGION = 'us-central1'
|
||||
|
||||
const Lyrics = ({ navigation }) => {
|
||||
const scrollRef = useRef(null)
|
||||
@@ -142,6 +142,11 @@ const Lyrics = ({ navigation }) => {
|
||||
|
||||
const [titleValue, setTitleValue] = useState(initial.title || '')
|
||||
const [sections, setSections] = useState(initial.sections || [])
|
||||
const moderationRequestRef = useRef({
|
||||
signature: '',
|
||||
data: null,
|
||||
promise: null,
|
||||
})
|
||||
const setSectionAt = (index, value) => {
|
||||
setSections((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(() => {
|
||||
closeSensitiveContentModal(false)
|
||||
}, [closeSensitiveContentModal])
|
||||
@@ -240,10 +316,7 @@ const Lyrics = ({ navigation }) => {
|
||||
|
||||
// 1) Appel de la Cloud Function de modération avant tout enregistrement
|
||||
try {
|
||||
const callable = getFunctionsClient(FUNCTIONS_REGION).httpsCallable(
|
||||
'lyrics-analyseLyricsToxicity'
|
||||
)
|
||||
const { data } = await callable({
|
||||
const data = await getModerationResult({
|
||||
title: titleTrimmed,
|
||||
lyrics: normalizedNewLyrics,
|
||||
})
|
||||
@@ -375,6 +448,7 @@ const Lyrics = ({ navigation }) => {
|
||||
projectSelections,
|
||||
alertMessage,
|
||||
confirmSensitiveContent,
|
||||
getModerationResult,
|
||||
sanitize,
|
||||
selectedProjectId,
|
||||
projectHasLyrics,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { StyleSheet, Text, View } from 'react-native'
|
||||
import useMinuit from 'react-native-minuit/src/hooks/useMinuit.js'
|
||||
import { background } from '../../assets'
|
||||
import { background, videos } from '../../assets'
|
||||
import GradientButton from '../../components/GradientButton'
|
||||
import MusicLandHeader from '../../components/MusicLandHeader'
|
||||
import { strings } from '../../constants/strings'
|
||||
@@ -18,15 +18,26 @@ const CTA_BUTTON_HEIGHT = 58
|
||||
const CTA_BUTTON_MAX_WIDTH = 520
|
||||
|
||||
const WritingLyrics = () => {
|
||||
const { createNewProject, selectedProjectId, updateProjectData, videos } = useUserData()
|
||||
const { createNewProject, currentUserData, selectedProjectId, updateProjectData } =
|
||||
useUserData()
|
||||
const { setIsLoading } = useMinuit()
|
||||
|
||||
const [videoUrl, setVideoUrl] = useState(null)
|
||||
const preferredLanguage = currentUserData
|
||||
? currentUserData.preferredLanguage === 'en'
|
||||
? 'en'
|
||||
: 'fr'
|
||||
: null
|
||||
|
||||
useEffect(() => {
|
||||
setVideoUrl(isWeb ? videos.celineWeb : videos?.celine)
|
||||
// setVideoUrl(videos?.test);
|
||||
}, [])
|
||||
if (!preferredLanguage) {
|
||||
return
|
||||
}
|
||||
|
||||
setVideoUrl(
|
||||
preferredLanguage === 'en' ? videos.writingSpaceIntroEn : videos.writingSpaceIntroFr
|
||||
)
|
||||
}, [preferredLanguage])
|
||||
|
||||
const startWriting = React.useCallback(async () => {
|
||||
try {
|
||||
@@ -66,7 +77,13 @@ const WritingLyrics = () => {
|
||||
<Text style={styles.secondaryNote}>{strings.writing.onboarding.secondaryNote}</Text>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BlurView } from 'expo-blur'
|
||||
import * as ImagePicker from 'expo-image-picker'
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Modal, Platform, Pressable, StyleSheet, Text, View } from 'react-native'
|
||||
import useMinuit from 'react-native-minuit/src/hooks/useMinuit'
|
||||
@@ -7,6 +8,7 @@ import GradientButton from '../../components/GradientButton'
|
||||
import { Input } from '../../components/Input'
|
||||
import MusicLandHeader from '../../components/MusicLandHeader'
|
||||
import firebase, { projectsRef, usersRef } from '../../config/firebase'
|
||||
import { uploadFileToFirebase } from '../../helpers/uploadToFirebase'
|
||||
import Page from '../../layouts/Page'
|
||||
import { Routes } from '../../navigation'
|
||||
import { goBack, navigate } from '../../navigation/NavigationService'
|
||||
@@ -16,6 +18,8 @@ import { FONT_FAMILY } from '../../styles/Fonts'
|
||||
import { buildFullName } from '../../utils/artistName'
|
||||
import { background } from '../../assets'
|
||||
|
||||
const COVER_TEMPLATE = 'MUSICLAND_PLAYBACKER'
|
||||
|
||||
export const ChooseCoverType = () => {
|
||||
const isIOS = Platform.OS === 'ios'
|
||||
const [choiceVisible, setChoiceVisible] = useState(false)
|
||||
@@ -27,8 +31,14 @@ export const ChooseCoverType = () => {
|
||||
const [savingChoice, setSavingChoice] = useState(false)
|
||||
const [savingPseudo, setSavingPseudo] = useState(false)
|
||||
|
||||
const { selectedProject, selectedProjectId, currentUserData, currentUID } = useUserData()
|
||||
const { setTooltip } = useMinuit()
|
||||
const {
|
||||
selectedProject,
|
||||
selectedProjectId,
|
||||
updateProjectData,
|
||||
currentUserData,
|
||||
currentUID,
|
||||
} = useUserData()
|
||||
const { setIsLoading, setTooltip } = useMinuit()
|
||||
|
||||
const projectId = selectedProject?.id || selectedProjectId || null
|
||||
const hasArtistPreference = useMemo(() => {
|
||||
@@ -104,6 +114,64 @@ export const ChooseCoverType = () => {
|
||||
ensureArtistPreference(() => navigate(Routes.PouchReady))
|
||||
}, [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(() => {
|
||||
setChoiceVisible(false)
|
||||
setChoiceDismissAction(null)
|
||||
@@ -259,10 +327,10 @@ export const ChooseCoverType = () => {
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{/* <BorderGradientButton
|
||||
title="Choisir une image"
|
||||
onPress={handlePickUserImage}
|
||||
/> */}
|
||||
<BorderGradientButton
|
||||
title="Choisir l’image centrale"
|
||||
onPress={handlePickCenterImage}
|
||||
/>
|
||||
<GradientButton
|
||||
title="Voir ma pochette"
|
||||
onPress={handleShowCover}
|
||||
|
||||
@@ -40,6 +40,7 @@ const PouchReady = () => {
|
||||
? selectedProject.userName.trim()
|
||||
: currentUserData?.userName || 'MusicLand'
|
||||
const profilePictureURL = currentUserData?.profilePictureURL || null
|
||||
const centerImageURL = selectedProject?.coverCenterImageURL || profilePictureURL
|
||||
|
||||
const selectedOption = useMemo(() => {
|
||||
if (!coverUrl) {
|
||||
@@ -119,7 +120,7 @@ const PouchReady = () => {
|
||||
}
|
||||
const coverBlob = await createMusiclandPlaybackCover({
|
||||
artistName,
|
||||
profilePictureURL,
|
||||
profilePictureURL: centerImageURL,
|
||||
})
|
||||
const storagePath = `users/${userId}/projects/${selectedProjectId}/cover-musicland-playbacker.png`
|
||||
const { resultURI } = await uploadFileToFirebase({
|
||||
@@ -171,7 +172,7 @@ const PouchReady = () => {
|
||||
}, [
|
||||
artistName,
|
||||
coverUrl,
|
||||
profilePictureURL,
|
||||
centerImageURL,
|
||||
selectedOption,
|
||||
selectedProject,
|
||||
selectedProjectId,
|
||||
@@ -184,12 +185,16 @@ const PouchReady = () => {
|
||||
<View style={styles.pageContent}>
|
||||
<CreateLyricsHeader
|
||||
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]}>
|
||||
<MusiclandPlaybackCover
|
||||
artistName={artistName}
|
||||
profilePictureURL={profilePictureURL}
|
||||
profilePictureURL={centerImageURL}
|
||||
coverUrl={coverUrl}
|
||||
style={isWeb ? styles.coverWeb : styles.coverMobile}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
@@ -24,6 +24,19 @@ export const getRouteAfterAuthentication = (userData = {}) =>
|
||||
? Routes.RegistrationChoice
|
||||
: Routes.BottomTab
|
||||
|
||||
export const getMainMenuReset = () => ({
|
||||
index: 0,
|
||||
routes: [
|
||||
{
|
||||
name: Routes.BottomTab,
|
||||
params: {
|
||||
screen: Routes.HomeStack,
|
||||
params: { screen: Routes.Home },
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
export const getExperienceReset = (experience) => {
|
||||
if (experience === 'listen') {
|
||||
return {
|
||||
@@ -37,18 +50,7 @@ export const getExperienceReset = (experience) => {
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
index: 0,
|
||||
routes: [
|
||||
{
|
||||
name: Routes.BottomTab,
|
||||
params: {
|
||||
screen: Routes.HomeStack,
|
||||
params: { screen: Routes.Home },
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
return getMainMenuReset()
|
||||
}
|
||||
|
||||
export const completeRegistrationFlow = async (experience) => {
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
{
|
||||
"$schema": "https://openapi.vercel.sh/vercel.json",
|
||||
"rewrites": [
|
||||
{ "source": "/link", "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" }
|
||||
{ "source": "/(.*)", "destination": "/index.html" }
|
||||
],
|
||||
"headers": [
|
||||
{
|
||||
"source": "/.web/well-known/apple-app-site-association",
|
||||
"source": "/.well-known/apple-app-site-association",
|
||||
"headers": [
|
||||
{
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||