diff --git a/.easignore b/.easignore
index a387210..b4ff357 100644
--- a/.easignore
+++ b/.easignore
@@ -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/
-
diff --git a/.gitignore b/.gitignore
index f277a53..40d68e6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -80,3 +80,5 @@ dist/
# @end expo-cli
*.sh
+.vercel
+.env*
diff --git a/app.json b/app.json
index 9139fb2..91a0cf4 100644
--- a/app.json
+++ b/app.json
@@ -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",
diff --git a/config/GoogleService-Info.plist b/config/GoogleService-Info.plist
index 5390048..b5edd4c 100644
--- a/config/GoogleService-Info.plist
+++ b/config/GoogleService-Info.plist
@@ -3,9 +3,9 @@
CLIENT_ID
- 305598753437-t2uitr9gd7aaed59ahdvklkcsg0vngmk.apps.googleusercontent.com
+ 305598753437-820ot12hdlpfkvhb33lj56jp4752p3un.apps.googleusercontent.com
REVERSED_CLIENT_ID
- com.googleusercontent.apps.305598753437-t2uitr9gd7aaed59ahdvklkcsg0vngmk
+ com.googleusercontent.apps.305598753437-820ot12hdlpfkvhb33lj56jp4752p3un
ANDROID_CLIENT_ID
305598753437-2oe78um7k9fagc9cjrb9vurldn5a0a4c.apps.googleusercontent.com
API_KEY
@@ -15,7 +15,7 @@
PLIST_VERSION
1
BUNDLE_ID
- com.omedis.musicland.ai
+ Ai.musicland
PROJECT_ID
musicland-d33f9
STORAGE_BUCKET
@@ -31,6 +31,6 @@
IS_SIGNIN_ENABLED
GOOGLE_APP_ID
- 1:305598753437:ios:7942793c4093dedff28274
+ 1:305598753437:ios:4a6410dbe02ece31f28274
-
\ No newline at end of file
+
diff --git a/design-qa.md b/design-qa.md
index 3a33d1d..3318e72 100644
--- a/design-qa.md
+++ b/design-qa.md
@@ -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
diff --git a/eas.json b/eas.json
index 53481d4..5e2f086 100644
--- a/eas.json
+++ b/eas.json
@@ -21,9 +21,17 @@
"resourceClass": "large"
},
"ios": {
+ "image": "latest",
"buildConfiguration": "Release",
"autoIncrement": "buildNumber"
}
}
+ },
+ "submit": {
+ "production": {
+ "ios": {
+ "ascAppId": "6796655773"
+ }
+ }
}
}
diff --git a/public/.well-known/apple-app-site-association b/public/.well-known/apple-app-site-association
new file mode 100644
index 0000000..467d4d8
--- /dev/null
+++ b/public/.well-known/apple-app-site-association
@@ -0,0 +1,21 @@
+{
+ "applinks": {
+ "apps": [],
+ "details": [
+ {
+ "appID": "SD7SD54VR9.Ai.musicland",
+ "paths": [
+ "/music/*",
+ "/musics/*",
+ "/playback/*",
+ "/playbacks/*",
+ "/task/*",
+ "/tasks/*"
+ ]
+ }
+ ]
+ },
+ "webcredentials": {
+ "apps": ["SD7SD54VR9.Ai.musicland"]
+ }
+}
diff --git a/src/assets/UI/libraryMainBackground.jpg b/src/assets/UI/libraryMainBackground.jpg
new file mode 100644
index 0000000..4d9cfc0
Binary files /dev/null and b/src/assets/UI/libraryMainBackground.jpg differ
diff --git a/src/assets/UI/musiclandPlayback.png b/src/assets/UI/musiclandPlayback.png
new file mode 100644
index 0000000..e42e657
Binary files /dev/null and b/src/assets/UI/musiclandPlayback.png differ
diff --git a/src/assets/UI/musiclandStreaming.png b/src/assets/UI/musiclandStreaming.png
new file mode 100644
index 0000000..423eb6a
Binary files /dev/null and b/src/assets/UI/musiclandStreaming.png differ
diff --git a/src/assets/UI/playbackMainBackground.jpg b/src/assets/UI/playbackMainBackground.jpg
new file mode 100644
index 0000000..81191a4
Binary files /dev/null and b/src/assets/UI/playbackMainBackground.jpg differ
diff --git a/src/assets/UI/videoSpaceBackground.jpg b/src/assets/UI/videoSpaceBackground.jpg
new file mode 100644
index 0000000..a3c0987
Binary files /dev/null and b/src/assets/UI/videoSpaceBackground.jpg differ
diff --git a/src/assets/icons/video.png b/src/assets/icons/video.png
index 68fb707..b0a0cfa 100644
Binary files a/src/assets/icons/video.png and b/src/assets/icons/video.png differ
diff --git a/src/assets/index.js b/src/assets/index.js
index 8451bff..a460ae1 100644
--- a/src/assets/index.js
+++ b/src/assets/index.js
@@ -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 = {
diff --git a/src/assets/video/language-background-web.mp4 b/src/assets/video/language-background-web.mp4
new file mode 100644
index 0000000..6f0a201
Binary files /dev/null and b/src/assets/video/language-background-web.mp4 differ
diff --git a/src/assets/video/video-space-intro-en.mp4 b/src/assets/video/video-space-intro-en.mp4
new file mode 100644
index 0000000..01cca9f
Binary files /dev/null and b/src/assets/video/video-space-intro-en.mp4 differ
diff --git a/src/assets/video/video-space-intro-fr.mp4 b/src/assets/video/video-space-intro-fr.mp4
new file mode 100644
index 0000000..16bf26b
Binary files /dev/null and b/src/assets/video/video-space-intro-fr.mp4 differ
diff --git a/src/assets/video/writing-space-intro-en.mp4 b/src/assets/video/writing-space-intro-en.mp4
new file mode 100644
index 0000000..dea97c9
Binary files /dev/null and b/src/assets/video/writing-space-intro-en.mp4 differ
diff --git a/src/assets/video/writing-space-intro-fr.mp4 b/src/assets/video/writing-space-intro-fr.mp4
new file mode 100644
index 0000000..08a4f08
Binary files /dev/null and b/src/assets/video/writing-space-intro-fr.mp4 differ
diff --git a/src/components/BackgroundVideo.native.js b/src/components/BackgroundVideo.native.js
index ebe0bea..ed79390 100644
--- a/src/components/BackgroundVideo.native.js
+++ b/src/components/BackgroundVideo.native.js
@@ -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}
/>
- setMuted((currentMuted) => !currentMuted)}
- style={({ pressed }) => [styles.soundButton, pressed && styles.soundButtonPressed]}
- >
-
- {muted ? 'Activer le son' : 'Couper le son'}
-
+ {!silent ? (
+ setMuted((currentMuted) => !currentMuted)}
+ style={({ pressed }) => [styles.soundButton, pressed && styles.soundButtonPressed]}
+ >
+
+
+ {muted ? 'Activer le son' : 'Couper le son'}
+
+
+ ) : null}
)
}
diff --git a/src/components/BackgroundVideo.web.js b/src/components/BackgroundVideo.web.js
index 3e55f82..ce97bbc 100644
--- a/src/components/BackgroundVideo.web.js
+++ b/src/components/BackgroundVideo.web.js
@@ -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}
/>
- setMuted((currentMuted) => !currentMuted)}
- style={({ hovered, pressed }) => [
- styles.soundButton,
- soundButtonStyle,
- hovered && styles.soundButtonHovered,
- pressed && styles.soundButtonPressed,
- ]}
- >
-
- {muted ? 'Activer le son' : 'Couper le son'}
-
+ {!silent ? (
+ setMuted((currentMuted) => !currentMuted)}
+ style={({ hovered, pressed }) => [
+ styles.soundButton,
+ soundButtonStyle,
+ hovered && styles.soundButtonHovered,
+ pressed && styles.soundButtonPressed,
+ ]}
+ >
+
+
+ {muted ? 'Activer le son' : 'Couper le son'}
+
+
+ ) : null}
)
}
diff --git a/src/components/FullscreenIntroVideo.native.js b/src/components/FullscreenIntroVideo.native.js
index 1e1e820..0c0eac7 100644
--- a/src/components/FullscreenIntroVideo.native.js
+++ b/src/components/FullscreenIntroVideo.native.js
@@ -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 ? (
(
+
+
+
+)
+
+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)
diff --git a/src/components/modal/NativeDateModal.js b/src/components/modal/NativeDateModal.js
new file mode 100644
index 0000000..854eacf
--- /dev/null
+++ b/src/components/modal/NativeDateModal.js
@@ -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 ? (
+
+ ) : null
+ }
+
+ return (
+
+
+
+
+ Date de naissance
+
+
+ [styles.button, styles.cancelButton, pressed && styles.pressed]}
+ >
+ Annuler
+
+ {
+ onValidate(selectedDate)
+ onClose()
+ }}
+ style={({ pressed }) => [styles.button, styles.validateButton, pressed && styles.pressed]}
+ >
+ Valider
+
+
+
+
+
+ )
+}
+
+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
diff --git a/src/config/firebase.js b/src/config/firebase.js
index 5497e0b..04ac5f3 100644
--- a/src/config/firebase.js
+++ b/src/config/firebase.js
@@ -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 = false
diff --git a/src/hooks/useSocialAuth.js b/src/hooks/useSocialAuth.js
index 19cacb8..5425c1f 100644
--- a/src/hooks/useSocialAuth.js
+++ b/src/hooks/useSocialAuth.js
@@ -89,13 +89,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 =
diff --git a/src/navigation/BottomTab.js b/src/navigation/BottomTab.js
index efe5bab..12f9685 100644
--- a/src/navigation/BottomTab.js
+++ b/src/navigation/BottomTab.js
@@ -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),
diff --git a/src/providers/NotificationProvider.js b/src/providers/NotificationProvider.js
index 541e340..82f716c 100644
--- a/src/providers/NotificationProvider.js
+++ b/src/providers/NotificationProvider.js
@@ -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'
diff --git a/src/providers/UniversalLinkProvider.js b/src/providers/UniversalLinkProvider.js
index 9b86629..5536199 100644
--- a/src/providers/UniversalLinkProvider.js
+++ b/src/providers/UniversalLinkProvider.js
@@ -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) {
diff --git a/src/screens/CreatePassword.js b/src/screens/CreatePassword.js
index 6d3dc23..5625e11 100644
--- a/src/screens/CreatePassword.js
+++ b/src/screens/CreatePassword.js
@@ -116,7 +116,7 @@ const CreatePassword = ({ navigation }) => {
return (
diff --git a/src/screens/HitParade/HitParade.js b/src/screens/HitParade/HitParade.js
index f4a5ce2..8d7466e 100644
--- a/src/screens/HitParade/HitParade.js
+++ b/src/screens/HitParade/HitParade.js
@@ -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 = [
@@ -466,11 +466,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 = () => (
@@ -681,11 +695,10 @@ const HitParade = () => {
) : null}
- {/* */}
-
+
{
const { setTooltip } = useMinuit()
const [videoUrl, setVideoUrl] = useState(null)
const navigateToMusicDetails = useNavigateToMusicDetails()
+ const preferredLanguage = currentUserData?.preferredLanguage === 'en' ? 'en' : 'fr'
if (!currentUID) {
return
@@ -314,7 +318,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 {
@@ -390,9 +396,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(
@@ -413,8 +423,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]
)
@@ -438,6 +452,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}
diff --git a/src/screens/Home/components/ClubCard.js b/src/screens/Home/components/ClubCard.js
index 5196dc1..cdc6fa7 100644
--- a/src/screens/Home/components/ClubCard.js
+++ b/src/screens/Home/components/ClubCard.js
@@ -16,7 +16,7 @@ const ClubCard = ({ onPress, containerStyle = null, variant = 'compact' }) => {
[
styles.card,
cardBaseStyle,
@@ -24,7 +24,7 @@ const ClubCard = ({ onPress, containerStyle = null, variant = 'compact' }) => {
pressed && styles.cardPressed,
]}
>
-
+
)
}
diff --git a/src/screens/Home/components/StageCard.js b/src/screens/Home/components/StageCard.js
index 976ec6a..29c6a92 100644
--- a/src/screens/Home/components/StageCard.js
+++ b/src/screens/Home/components/StageCard.js
@@ -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,
]}
/>
@@ -206,6 +208,9 @@ const StageCard = ({
[
basePressableStyle,
containerStyle,
diff --git a/src/screens/LandingPage.js b/src/screens/LandingPage.js
index 8320251..130c229 100644
--- a/src/screens/LandingPage.js
+++ b/src/screens/LandingPage.js
@@ -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 (
<>
-
+
+ ) : null
+ }
+ >
{
hasLikedClips
return (
-
+
{Platform.OS !== 'web' ? (
{
}, [dropdownVisible, closeDropdown])
return (
-
+
{
- 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
@@ -69,14 +82,6 @@ const Playback = ({ route, navigation }) => {
- ) : null
- }
containerStyle={isWeb ? styles.videoPage : undefined}
>
{/* */}
@@ -115,6 +120,13 @@ const Playback = ({ route, navigation }) => {
{/* */}
+ setVideoUrl(null)}
+ contentFit={isWeb ? 'contain' : 'cover'}
+ skipLabel={preferredLanguage === 'en' ? 'Skip video' : 'Passer la vidéo'}
+ />
)
}
@@ -126,10 +138,6 @@ const styles = StyleSheet.create({
alignSelf: 'flex-start',
marginLeft: '4%',
},
- rightSideControl: {
- right: 24,
- left: 'auto',
- },
blockedText: {
color: Palette.white,
opacity: 0.75,
diff --git a/src/screens/Playback/PlaybackAi.js b/src/screens/Playback/PlaybackAi.js
index de051af..2f0128a 100644
--- a/src/screens/Playback/PlaybackAi.js
+++ b/src/screens/Playback/PlaybackAi.js
@@ -193,7 +193,7 @@ const PlaybackAi = ({ route }) => {
return (
diff --git a/src/screens/Playback/PlaybackAiStatus.js b/src/screens/Playback/PlaybackAiStatus.js
index f576de8..413d96b 100644
--- a/src/screens/Playback/PlaybackAiStatus.js
+++ b/src/screens/Playback/PlaybackAiStatus.js
@@ -96,7 +96,7 @@ const PlaybackAiStatus = ({ route }) => {
return (
diff --git a/src/screens/Playback/PlaybackAiStatus.web.js b/src/screens/Playback/PlaybackAiStatus.web.js
index f1dfc50..fbecad9 100644
--- a/src/screens/Playback/PlaybackAiStatus.web.js
+++ b/src/screens/Playback/PlaybackAiStatus.web.js
@@ -91,7 +91,7 @@ const PlaybackAiStatus = ({ route }) => {
return (
diff --git a/src/screens/Playback/PlaybackGuide.js b/src/screens/Playback/PlaybackGuide.js
index 78353f5..3a26ff1 100644
--- a/src/screens/Playback/PlaybackGuide.js
+++ b/src/screens/Playback/PlaybackGuide.js
@@ -27,14 +27,13 @@ const PlaybackGuide = ({ navigation }) => {
showsVerticalScrollIndicator={false}
>
- 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
-
- Introduction : Tu Es l'Artiste ET le Performeur !
-
+ Introduction
+ Tu es l'artiste et l’interprête!
- 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.
- La Règle des 15 Écoutes - Version Créateur
+ La Règle des 12 Écoutes - Version Créateur
- 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
:
@@ -69,12 +68,12 @@ const PlaybackGuide = ({ navigation }) => {
sur-articulés ?{'\n'}• Où sont tes respirations naturelles dans l'enregistrement ?
- Écoutes 11-15 : La Transformation Maintenant,
- détache-toi du "toi qui a enregistré" pour devenir le "toi qui performe". C'est là que
- la magie opère !
+ Écoutes 11-12 : La Transformation Maintenant,
+ détache-toi du "toi qui a enregistré" pour devenir le "toi qui interprête". C'est là
+ que la magie opère !
- Les Secrets du Créateur-Performeur
+ Les Secrets du PLAYBACKER
Le Phénomène de Dédoublement 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 }) => {
Les Angles Qui Racontent Ton Histoire
- L'Angle "Confession" (Très Proche) Pour les parties
- intimes de ta chanson. Le spectateur doit sentir que tu lui confies un secret.
+ L'Angle "Confession" (Très Proche) Pour une
+ interprêtation intimes de ta chanson. Le spectateur doit sentir que tu lui confies un
+ secret.
L'Angle "Concert" (Plan Large) Montre l'artiste
@@ -125,9 +125,10 @@ const PlaybackGuide = ({ navigation }) => {
C'est encore plus puissant quand c'est TA chanson !
- 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
@@ -152,7 +153,7 @@ const PlaybackGuide = ({ navigation }) => {
Les Scènes Qui Amplifient Ta Création
L'Endroit Où Tu l'as Écrite - Le Retour aux Sources{' '}
- 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.
@@ -174,10 +175,12 @@ const PlaybackGuide = ({ navigation }) => {
Suggestions Créatives Uniques :
• 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!
Chapitre 4 : Le Mindset du Créateur-Performeur
@@ -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
Résumé : Ton Parcours Créatif Unique
@@ -266,8 +269,10 @@ const PlaybackGuide = ({ navigation }) => {
Ton Défi Ultime : La Première Mondiale !
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 !
+
+
+ Elle ne doit pas être parfaite, elle doit être authentique!
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.
- Le monde attend de découvrir l'artiste complet que tu es ! 🎤🎵✨
+ Le monde attend de découvrir l'artiste qui est en toi ! 🎤🎵✨
-
- #MonPlaybackMaChanson #CréateurPerformeur #DoubleArtiste
+
+ #MonPlaybackMaChanson #Playbacker-performer #Artisteletemps d’une chanson
-
+
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 !
@@ -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
diff --git a/src/screens/Playback/RecordPlayback.js b/src/screens/Playback/RecordPlayback.js
index c43489f..34abdcb 100644
--- a/src/screens/Playback/RecordPlayback.js
+++ b/src/screens/Playback/RecordPlayback.js
@@ -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 (
-
-
-
-
+
+ {cameraCanMount && (
+
+ )}
-
-
-
+
+
-
- {/* Overlay de compte à rebours : on affiche 5→1 pour éviter l'effet visuel à 1 */}
- {isPreparing && countdown >= 1 && !showProgress && (
-
+
+
+
+
+ {/* Overlay de compte à rebours : on affiche 5→1 pour éviter l'effet visuel à 1 */}
+ {isPreparing && countdown >= 1 && !showProgress && (
+
+
-
- {countdown}
-
-
- )}
-
- {/* Permission prompt */}
- {!permissionsGranted && (
-
- {
- try {
- if (!cameraPermission?.granted) await requestCameraPermission()
- } catch (_) {}
- }}
- />
-
- )}
-
-
- {/* Start button */}
- {permissionsGranted && !isPreparing && !isRecording && (
-
+ {countdown}
+
+
)}
- {/* Progress circulaire + restart */}
- {permissionsGranted && (isRecording || showProgress || isPreparing) && (
+ {/* Permission prompt */}
+ {!permissionsGranted && (
+ {
+ try {
+ if (!cameraPermission?.granted) await requestCameraPermission()
+ } catch (_) {}
+ }}
+ />
+
+ )}
+
+ {cameraCanMount && !cameraReady && !cameraMountError && (
+
+
+
+ Initialisation de la caméra…
+
+
+ )}
+
+ {cameraMountError && (
+
+
+ Impossible d'afficher la caméra. Réessaie dans un instant.
+
+
+
+ )}
+
+
+ {/* Start button */}
+ {cameraReady && !isPreparing && !isRecording && (
+
+ )}
+
+ {/* Progress circulaire + restart */}
+ {cameraReady && (isRecording || showProgress || isPreparing) && (
+
+
-
+
-
-
-
-
-
+
+
- )}
-
-
+
+ )}
+
{/* CreateLyricsHeader overlay outside of CameraView */}
{
const [activeIndex, setActiveIndex] = useState(0)
@@ -21,7 +23,13 @@ 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),
format: (docs) => docs.filter((item) => item?.playbackPublishedOnMusicLand !== false),
simpleRef: false,
@@ -29,18 +37,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 onSnap = useCallback(
(index) => {
@@ -64,6 +77,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
@@ -71,6 +92,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) {
@@ -108,6 +135,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'
@@ -116,17 +149,27 @@ const Playbacks = () => {
if (!nextId) {
return
}
+ if (project && typeof project === 'object') {
+ setPlaybackType(getPlaybackType(project))
+ }
setFocusProjectId(nextId)
setViewMode('feed')
}, [])
if (!showFeed) {
- return
+ return (
+
+ )
}
return (
{
/>
)}
/>
- setViewMode('charts')}
- accessibilityLabel="Revenir au classement des playbacks"
- >
- Classement
-
+ {playbacks.length === 0 && !loading ? (
+
+
+ Aucun {playbackType === PLAYBACK_TYPE.AI ? 'playback IA' : 'playback live'} pour le moment.
+
+
+ ) : null}
+
+
+ setViewMode('charts')}
+ accessibilityLabel="Revenir au classement des playbacks"
+ >
+ Classement
+
+
)
}
@@ -157,10 +214,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,
@@ -172,4 +237,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',
+ },
})
diff --git a/src/screens/Playbacks/Playbacks.web.js b/src/screens/Playbacks/Playbacks.web.js
index 9161b30..9c4ed24 100644
--- a/src/screens/Playbacks/Playbacks.web.js
+++ b/src/screens/Playbacks/Playbacks.web.js
@@ -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()
@@ -44,18 +47,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
@@ -188,6 +196,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) {
@@ -207,6 +223,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)
@@ -335,6 +357,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'
@@ -343,12 +372,21 @@ const Playbacks = () => {
if (!nextId) {
return
}
+ if (project && typeof project === 'object') {
+ setPlaybackType(getPlaybackType(project))
+ }
setFocusProjectId(nextId)
setViewMode('feed')
}, [])
if (!showFeed) {
- return
+ return (
+
+ )
}
return (
@@ -378,6 +416,7 @@ const Playbacks = () => {
) : null}
String(item?.id || index)}
@@ -402,6 +441,13 @@ const Playbacks = () => {
onScroll={onScroll}
scrollEventThrottle={16}
/>
+ {playbacks.length === 0 && !loading ? (
+
+
+ Aucun {playbackType === PLAYBACK_TYPE.AI ? 'playback IA' : 'playback live'} pour le moment.
+
+
+ ) : null}
{showIndicators && (
{
)}
- setViewMode('charts')}
- accessibilityLabel="Revenir au classement des playbacks"
- >
-
- Retour au classement
-
+
+
+ setViewMode('charts')}
+ accessibilityLabel="Revenir au classement des playbacks"
+ >
+
+ Retour au classement
+
+
//
)
@@ -488,6 +541,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,
@@ -572,9 +636,6 @@ const styles = StyleSheet.create({
textAlign: 'center',
},
backToCharts: {
- position: 'absolute',
- top: 28,
- right: 24,
paddingVertical: 10,
paddingHorizontal: 14,
borderRadius: 999,
@@ -596,4 +657,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',
+ },
})
diff --git a/src/screens/Playbacks/components/PlaybackCharts.js b/src/screens/Playbacks/components/PlaybackCharts.js
index 125243f..3ecd471 100644
--- a/src/screens/Playbacks/components/PlaybackCharts.js
+++ b/src/screens/Playbacks/components/PlaybackCharts.js
@@ -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 { data: allPlaybacks, loading: allPlaybacksLoading } = useDataFromRef({
@@ -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 = () => (
+
+ Aucun {playbackTypeLabel.toLowerCase()} pour le moment.
+
+ )
const renderHero = () => (
@@ -289,6 +314,7 @@ const PlaybackCharts = ({ onPlaybackPress }) => {
data={playbacksList}
keyExtractor={(item) => item.id}
refreshing={allPlaybacksLoading}
+ ListEmptyComponent={renderEmptyState}
renderItem={({ item }) => (
{
onPress={() => handlePlaybackSelected(item)}
/>
))}
+ {playbacksList.length === 0 ? renderEmptyState() : null}
)
@@ -435,7 +462,7 @@ const PlaybackCharts = ({ onPlaybackPress }) => {
fontFamily: FONT_FAMILY.HelveticaNeueBold,
}}
>
- Playbacks
+ {playbackTypeLabel}
{
return (
{
) : null}
-
+
+
+
{isWeb && (
{
}
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',
+ },
+})
diff --git a/src/screens/Playbacks/components/PlaybackTypeTabs.js b/src/screens/Playbacks/components/PlaybackTypeTabs.js
new file mode 100644
index 0000000..b8e2b20
--- /dev/null
+++ b/src/screens/Playbacks/components/PlaybackTypeTabs.js
@@ -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 }) => (
+
+ {options.map((option) => {
+ const isSelected = value === option.value
+
+ return (
+ onChange?.(option.value)}
+ style={({ pressed }) => [
+ styles.tab,
+ compact && styles.compactTab,
+ isSelected && styles.selectedTab,
+ pressed && styles.pressedTab,
+ ]}
+ >
+
+ {option.label}
+
+
+ )
+ })}
+
+)
+
+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',
+ },
+})
diff --git a/src/screens/Production/PlaybackDownload.js b/src/screens/Production/PlaybackDownload.js
index 2be17b4..952b7a8 100644
--- a/src/screens/Production/PlaybackDownload.js
+++ b/src/screens/Production/PlaybackDownload.js
@@ -12,7 +12,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'
@@ -444,6 +444,15 @@ const PlaybackDownload = ({ route }) => {
isDownloading,
pendingDownloadIntent,
])
+
+ const handleGoHome = useCallback(() => {
+ navigate(Routes.BottomTab, {
+ screen: Routes.HomeStack,
+ params: {
+ screen: Routes.Home,
+ },
+ })
+ }, [])
return (
<>
{
backgroundColor="#07111B"
headerType="NONE"
>
-
+
{
Paiement en attente de confirmation...
) : null}
+
>
@@ -586,4 +601,8 @@ const styles = StyleSheet.create({
color: Palette.white,
opacity: 0.8,
},
+ homeButton: {
+ marginTop: 2,
+ marginBottom: 18,
+ },
})
diff --git a/src/screens/Register.js b/src/screens/Register.js
index f9cb94c..a40b93e 100644
--- a/src/screens/Register.js
+++ b/src/screens/Register.js
@@ -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'
@@ -152,13 +152,6 @@ const Register = ({ navigation, route }) => {
})
}
- const onDateChange = (event, selectedDate) => {
- setShowDatePicker(Platform.OS === 'ios')
- if (selectedDate) {
- setBirthDate(selectedDate)
- }
- }
-
const renderFormContent = () => (
@@ -222,17 +215,13 @@ const Register = ({ navigation, route }) => {
/>
- {showDatePicker && (
-
- )}
+ setShowDatePicker(false)}
+ onValidate={setBirthDate}
+ initialDate={birthDate}
+ language={preferredLanguage}
+ />
>
)}
@@ -291,7 +280,7 @@ const Register = ({ navigation, route }) => {
{
return (
diff --git a/src/screens/Studio/ComposeSong.js b/src/screens/Studio/ComposeSong.js
index 46c32bf..380a08a 100644
--- a/src/screens/Studio/ComposeSong.js
+++ b/src/screens/Studio/ComposeSong.js
@@ -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
@@ -303,13 +300,7 @@ const ComposeSong = () => {
])
return (
- : null
- }
- headerType="NONE"
- >
+
{
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',
diff --git a/src/screens/Studio/SongReady.js b/src/screens/Studio/SongReady.js
index d538255..944c602 100644
--- a/src/screens/Studio/SongReady.js
+++ b/src/screens/Studio/SongReady.js
@@ -126,6 +126,7 @@ const SongReady = () => {
coverUrl: null,
coverStatus: null,
playbackUrl: null,
+ playbackProvider: null,
playbackStatus: null,
playbackGenerating: null,
songPublishedOnMusicLand: false,
diff --git a/src/screens/Writing/CreateLyricsWithAi.js b/src/screens/Writing/CreateLyricsWithAi.js
index c313d5f..862d292 100644
--- a/src/screens/Writing/CreateLyricsWithAi.js
+++ b/src/screens/Writing/CreateLyricsWithAi.js
@@ -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 (
: null
- }
+ backgroundImg={background.writingMobileBackground}
>
{
diff --git a/src/screens/Writing/CreatingLyrics.js b/src/screens/Writing/CreatingLyrics.js
index 65b2c19..0b6821a 100644
--- a/src/screens/Writing/CreatingLyrics.js
+++ b/src/screens/Writing/CreatingLyrics.js
@@ -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)
diff --git a/src/screens/Writing/Lyrics.js b/src/screens/Writing/Lyrics.js
index 9176504..6ecb36d 100644
--- a/src/screens/Writing/Lyrics.js
+++ b/src/screens/Writing/Lyrics.js
@@ -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,
diff --git a/src/screens/Writing/WritingLyrics.js b/src/screens/Writing/WritingLyrics.js
index 078e584..7e66bff 100644
--- a/src/screens/Writing/WritingLyrics.js
+++ b/src/screens/Writing/WritingLyrics.js
@@ -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 = () => {
{strings.writing.onboarding.secondaryNote}
- setVideoUrl(null)} />
+ setVideoUrl(null)}
+ contentFit={isWeb ? 'contain' : 'cover'}
+ skipLabel={preferredLanguage === 'en' ? 'Skip video' : 'Passer la vidéo'}
+ />
)
}
diff --git a/src/screens/cover/ChooseCoverType.js b/src/screens/cover/ChooseCoverType.js
index 69e25d9..84382fd 100644
--- a/src/screens/cover/ChooseCoverType.js
+++ b/src/screens/cover/ChooseCoverType.js
@@ -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,
}}
>
- {/* */}
+
{
? 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 = () => {
diff --git a/src/utils/playbackType.js b/src/utils/playbackType.js
new file mode 100644
index 0000000..c1bd1ef
--- /dev/null
+++ b/src/utils/playbackType.js
@@ -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
diff --git a/src/utils/registrationFlow.js b/src/utils/registrationFlow.js
index ba2d433..9c54827 100644
--- a/src/utils/registrationFlow.js
+++ b/src/utils/registrationFlow.js
@@ -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) => {
diff --git a/vercel.json b/vercel.json
index fbdfefb..31b84ec 100644
--- a/vercel.json
+++ b/vercel.json
@@ -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",
diff --git a/web/.well-known/apple-app-site-association b/web/.well-known/apple-app-site-association
deleted file mode 100644
index 1903bdb..0000000
--- a/web/.well-known/apple-app-site-association
+++ /dev/null
@@ -1,18 +0,0 @@
-{
- "applinks": {
- "apps": [],
- "details": [
- {
- "appID": "W2QZ9CTMYJ.com.omedis.musicland.ai",
- "paths": [
- "/music/*"
- ]
- }
- ]
- },
- "webcredentials": {
- "apps": [
- "W2QZ9CTMYJ.com.omedis.musicland.ai"
- ]
- }
-}