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" - ] - } -}