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/functions/firepit-log.txt b/functions/firepit-log.txt new file mode 100644 index 0000000..fd66895 --- /dev/null +++ b/functions/firepit-log.txt @@ -0,0 +1,14 @@ +Welcome to firepit v1.1.0! +Doing JSON parses for version checks at /snapshot/firepit/vendor/node_modules/firebase-tools/package.json +is-ci,mime,rc,yaml,abbrev,abort-controller,accepts,acorn,acorn-walk,agent-base,ajv,ajv-formats,ansi-align,ansi-escapes,ansi-regex,ansi-styles,any-promise,anymatch,archiver,archiver-utils,arg,argparse,array-flatten,arrify,as-array,ast-types,async,async-lock,asynckit,b4a,balanced-match,bare-events,bare-fs,bare-os,bare-path,bare-stream,bare-url,base64-js,basic-auth,basic-auth-connect,basic-ftp,bignumber.js,binary-extensions,bl,body-parser,boxen,brace-expansion,braces,buffer,buffer-crc32,buffer-equal-constant-time,bytes,call-bind-apply-helpers,call-bound,call-me-maybe,camelcase,chalk,char-regex,chardet,chokidar,chownr,ci-info,cjson,cli-boxes,cli-cursor,cli-highlight,cli-spinners,cli-table3,cli-width,cliui,clone,color,color-convert,color-name,color-string,colorette,combined-stream,commander,compress-commons,compressible,compression,concat-map,config-chain,configstore,connect,content-disposition,content-type,cookie,cookie-signature,core-util-is,cors,crc-32,crc32-stream,create-require,cross-env,cross-spawn,crypto-random-string,csv-parse,data-uri-to-buffer,debug,deep-equal-in-any-order,deep-extend,deep-freeze,deep-is,defaults,degenerator,delayed-stream,depd,destroy,diff,discontinuous-range,dot-prop,dunder-proto,duplexify,eastasianwidth,ecdsa-sig-formatter,ee-first,emoji-regex,emojilib,enabled,encodeurl,end-of-stream,env-paths,environment,es-define-property,es-errors,es-object-atoms,es-set-tostringtag,escalade,escape-goat,escape-html,escodegen,esprima,estraverse,esutils,etag,event-target-shim,events,events-listener,events-universal,eventsource,eventsource-parser,exegesis,exegesis-express,exponential-backoff,express,express-rate-limit,extend,fast-deep-equal,fast-fifo,fast-uri,fecha,fetch-blob,filesize,fill-range,finalhandler,firebase-tools,fn.name,foreground-child,form-data,formdata-polyfill,forwarded,fresh,fs-extra,function-bind,fuzzy,gaxios,gcp-metadata,get-caller-file,get-intrinsic,get-proto,get-uri,glob,glob-parent,glob-slash,glob-slasher,global-dirs,google-auth-library,google-gax,google-logging-utils,googleapis-common,gopd,graceful-fs,gtoken,has-flag,has-symbols,has-tostringtag,has-yarn,hasown,heap-js,highlight.js,hono,hosted-git-info,http-errors,http-proxy-agent,https-proxy-agent,iconv-lite,ieee754,ignore,import-lazy,imurmurhash,infer-owner,inherits,ini,install-artifact-from-github,ip-address,ip-regex,ipaddr.js,is-binary-path,is-extglob,is-fullwidth-code-point,is-glob,is-installed-globally,is-interactive,is-npm,is-number,is-obj,is-path-inside,is-promise,is-stream,is-stream-ended,is-typedarray,is-unicode-supported,is-url,is-wsl,is-yarn-global,is2,isarray,isexe,isomorphic-fetch,jackspeak,jju,join-path,jose,js-yaml,json-bigint,json-parse-helpfulerror,json-ptr,json-schema-traverse,json-schema-typed,jsonfile,jsonwebtoken,jwa,jws,kuler,lazystream,leven,libsodium,libsodium-wrappers,lodash,lodash._objecttypes,lodash.camelcase,lodash.includes,lodash.isboolean,lodash.isinteger,lodash.isnumber,lodash.isobject,lodash.isplainobject,lodash.isstring,lodash.once,lodash.snakecase,log-symbols,logform,long,lru-cache,lsofi,make-dir,make-error,marked,marked-terminal,math-intrinsics,media-typer,merge-descriptors,methods,mime-db,mime-types,mimic-fn,minimatch,minimist,minipass,minizlib,moo,morgan,ms,mute-stream,mz,nan,nearley,negotiator,netmask,node-domexception,node-emoji,node-fetch,node-gyp,nopt,normalize-path,npm-install-checks,npm-normalize-package-bin,npm-package-arg,npm-pick-manifest,object-assign,object-hash,object-inspect,on-finished,on-headers,once,one-time,onetime,open,openapi3-ts,ora,p-defer,p-limit,p-throttle,pac-proxy-agent,pac-resolver,package-json-from-dist,parse5,parse5-htmlparser2-tree-adapter,parseurl,path-key,path-scurry,path-to-regexp,pg,pg-cloudflare,pg-connection-string,pg-gateway,pg-int8,pg-pool,pg-protocol,pg-types,pglite-2,pgpass,picomatch,pkce-challenge,portfinder,postgres-array,postgres-bytea,postgres-date,postgres-interval,proc-log,process,process-nextick-args,progress,promise-breaker,proto-list,proto3-json-serializer,protobufjs,proxy-addr,proxy-agent,proxy-from-env,pupa,qs,railroad-diagrams,randexp,range-parser,raw-body,re2,readable-stream,readdir-glob,readdirp,registry-auth-token,registry-url,require-directory,require-from-string,restore-cursor,ret,retry,retry-request,rimraf,router,safe-buffer,safe-stable-stringify,safer-buffer,semver,semver-diff,send,serve-static,setprototypeof,shebang-command,shebang-regex,side-channel,side-channel-list,side-channel-map,side-channel-weakmap,signal-exit,skin-tone,smart-buffer,socks,socks-proxy-agent,sort-any,source-map,split2,sprintf-js,sql-formatter,stack-trace,statuses,stream-chain,stream-events,stream-json,stream-shift,streamx,string-width,string-width-cjs,string_decoder,strip-ansi,strip-ansi-cjs,strip-json-comments,stubs,superstatic,supports-color,supports-hyperlinks,tar,tar-stream,tcp-port-used,teeny-request,teex,text-decoder,text-hex,thenify,thenify-all,tinyglobby,tmp,to-regex-range,toidentifier,toxic,tr46,triple-beam,ts-node,tslib,tsscmp,type-fest,type-is,typedarray-to-buffer,typescript,undici,undici-types,unicode-emoji-modifier-base,unique-string,universal-analytics,universalify,unpipe,update-notifier-cjs,url-join,url-template,util-deprecate,utils-merge,uuid,v8-compile-cache-lib,valid-url,validate-npm-package-name,vary,wcwidth,web-streams-polyfill,webidl-conversions,whatwg-fetch,whatwg-url,which,widest-line,winston,winston-transport,wrap-ansi,wrap-ansi-cjs,wrappy,write-file-atomic,ws,xdg-basedir,xtend,y18n,yallist,yargs,yargs-parser,yn,yocto-queue,yoctocolors-cjs,zip-stream,zod,zod-to-json-schema,@apidevtools,@apphosting,@colors,@cspotcode,@dabh,@electric-sql,@google-cloud,@googleapis,@grpc,@hono,@inquirer,@isaacs,@jridgewell,@js-sdsl,@jsdevtools,@modelcontextprotocol,@npmcli,@opentelemetry,@pkgjs,@pnpm,@protobufjs,@sindresorhus,@so-ric,@tootallnate,@tsconfig,@types +Installed ft@15.17.0 and packaged ft@15.17.0 +Checking for npm/bin/npm-cli install at /Users/minuit/.cache/firebase/tools/lib/node_modules/npm/bin/npm-cli +Checking for npm/bin/npm-cli install at /Users/minuit/.cache/firebase/tools/node_modules/npm/bin/npm-cli +Checking for npm/bin/npm-cli install at /snapshot/firepit/node_modules/npm/bin/npm-cli +Found npm/bin/npm-cli install. +Checking for npm/bin/npm-cli install at /Users/minuit/.cache/firebase/tools/lib/node_modules/npm/bin/npm-cli +Checking for npm/bin/npm-cli install at /Users/minuit/.cache/firebase/tools/node_modules/npm/bin/npm-cli +Checking for npm/bin/npm-cli install at /snapshot/firepit/node_modules/npm/bin/npm-cli +Found npm/bin/npm-cli install. +ShellJSInternalError: EPERM: operation not permitted, chmod '/Users/minuit/.cache/firebase/runtime/shell' +Error: EPERM: operation not permitted, open '/Users/minuit/.cache/firebase/runtime/shell' \ No newline at end of file diff --git a/functions/helpers/email.js b/functions/helpers/email.js index 3934669..362cfe1 100644 --- a/functions/helpers/email.js +++ b/functions/helpers/email.js @@ -52,10 +52,106 @@ function basicTemplate({ title = '', content = '', button = null }) { ` } -function welcomeTemplate({ firstName = '', lastName = '' }) { - const fullName = [firstName, lastName].filter(Boolean).join(' ').trim() - const greeting = fullName ? `Salut ${fullName},` : 'Salut,' - return ` +function cleanEmailName(firstName) { + return typeof firstName === 'string' ? firstName.replace(/[\r\n]+/g, ' ').trim() : '' +} + +function escapeHtml(value) { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +function getWelcomeLanguage(preferredLanguage) { + return preferredLanguage === 'en' ? 'en' : 'fr' +} + +function welcomeEmailSubject({ firstName = '', preferredLanguage = 'fr' } = {}) { + const language = getWelcomeLanguage(preferredLanguage) + const cleanFirstName = cleanEmailName(firstName) + const subject = language === 'en' ? 'Welcome to Musicland' : 'Bienvenue à MusicLand' + return cleanFirstName ? `${subject}, ${cleanFirstName}` : subject +} + +function welcomeTemplate({ firstName = '', preferredLanguage = 'fr' } = {}) { + const language = getWelcomeLanguage(preferredLanguage) + const cleanFirstName = cleanEmailName(firstName) + const safeFirstName = escapeHtml(cleanFirstName) + const greeting = + language === 'en' + ? safeFirstName + ? `Hi ${safeFirstName},` + : 'Hi,' + : safeFirstName + ? `Salut ${safeFirstName},` + : 'Salut,' + const content = + language === 'en' + ? ` +

+ That’s it, you’ve got your pass to MusicLand. Welcome to the land of song! +

+

+ You’re about to embark on an extraordinary adventure! +

+

+ You’ll write your own lyrics, add a melody, shoot your music video… and upload your song to the MusicLand streaming platform. +

+

+ Three areas await you: +

+ +

+ And don’t worry, you don’t need to know how to sing, you don’t need any musical or technical skills, and you’ll be blown away by the result. +

+

+ See you soon in the halls of MusicLand, +

+

+ And… Feel the good vibe! +

+

+ The MusicLand Team +

` + : ` +

+ Ça y est : tu as ton pass pour MusicLand. Bienvenue au pays de la chanson ! +

+

+ Tu vas vivre une aventure extraordinaire! +

+

+ Tu vas écrire tes paroles, habiller ta mélodie, tourner ton clip… et déposer ta chanson sur la plateforme de streaming de MusicLand. +

+

+ Trois espaces t'attendent: +

+ +

+ Et rassure toi, tu n’as pas besoin de savoir chanter, tu n'as besoin d’aucune compétence musicale ou technique et tu vas être bluffé par le résultat. +

+

+ À très vite dans les allées de MusicLand, +

+

+ And… Feel the good vibe! +

+

+ L'équipe MusicLand +

` + + return ` @@ -86,16 +182,7 @@ function welcomeTemplate({ firstName = '', lastName = '' }) { />

${greeting}

-

Bienvenue sur MusicLand

-

- Nous sommes ravis de t'accueillir dans la communauté des artistes et passionnés de MusicLand. -

-

- Explore les playlists collaboratives, dépose tes projets et découvre ce que prépare la scène émergente. -

-

- L'équipe MusicLand -

+ ${content} @@ -107,4 +194,5 @@ function welcomeTemplate({ firstName = '', lastName = '' }) { } exports.basicTemplate = basicTemplate +exports.welcomeEmailSubject = welcomeEmailSubject exports.welcomeTemplate = welcomeTemplate diff --git a/functions/helpers/gemini.js b/functions/helpers/gemini.js index 7f17714..b640d0c 100644 --- a/functions/helpers/gemini.js +++ b/functions/helpers/gemini.js @@ -7,8 +7,46 @@ const { setTimeout } = require('timers/promises') // --- CONFIGURATION --- // Plus intelligente que le Flash original, ultra rapide, et stable sur l'API. -const TEXT_MODEL_NAME = 'gemini-flash-latest' +const TEXT_MODEL_NAME = 'gemini-3.5-flash' +const TEXT_FALLBACK_MODEL_NAME = 'gemini-3.1-flash-lite' const IMAGE_MODEL_NAME = 'gemini-3-pro-image' +const TEXT_GENERATION_MAX_ATTEMPTS = 3 +const TEXT_GENERATION_RETRY_DELAY_MS = 1000 + +const isRetryableTextGenerationError = (error) => { + const status = Number(error?.status || error?.response?.status) + return [429, 500, 502, 503, 504].includes(status) +} + +const generateTextWithRetry = async (ai, request, operation) => { + let lastError = null + + for (let attempt = 1; attempt <= TEXT_GENERATION_MAX_ATTEMPTS; attempt += 1) { + const modelName = + attempt === TEXT_GENERATION_MAX_ATTEMPTS ? TEXT_FALLBACK_MODEL_NAME : TEXT_MODEL_NAME + + try { + return await ai.generate({ + ...request, + model: googleAI.model(modelName), + }) + } catch (error) { + lastError = error + const shouldRetry = + isRetryableTextGenerationError(error) && attempt < TEXT_GENERATION_MAX_ATTEMPTS + + if (!shouldRetry) throw error + + const delayMs = TEXT_GENERATION_RETRY_DELAY_MS * 2 ** (attempt - 1) + console.warn( + `🔄 [${operation}] ${modelName} indisponible (${error?.status || 'réseau'}), nouvelle tentative ${attempt + 1}/${TEXT_GENERATION_MAX_ATTEMPTS} dans ${delayMs}ms` + ) + await setTimeout(delayMs) + } + } + + throw lastError +} // --- SINGLETON PATTERN (WARM START) --- // On stocke l'instance en dehors de la fonction pour la réutiliser @@ -39,15 +77,18 @@ exports.generateAI = async ({ system = '', prompt = '', schema }) => { const startedAt = Date.now() try { - const { output } = await ai.generate({ - model: googleAI.model(TEXT_MODEL_NAME), - system, - prompt, - output: { schema }, - config: { - temperature: 0.7, // Créativité équilibrée + const { output } = await generateTextWithRetry( + ai, + { + system, + prompt, + output: { schema }, + config: { + temperature: 0.7, // Créativité équilibrée + }, }, - }) + 'generateAI' + ) return output } catch (error) { @@ -97,7 +138,7 @@ exports.analyseLyrics = async ({ title = '', lyrics }) => { flagged: z.boolean().describe('Vrai si le contenu nécessite un avertissement.'), blocked: z.boolean().describe('Vrai UNIQUEMENT si violation grave (Haine, Violence réelle).'), score: z.number().min(0).max(1).describe('Score de risque (0=Sûr, 1=Dangereux).'), - reasons: z.array(z.string()).describe('Liste concise des raisons.'), + reasons: z.array(z.string()).max(5).describe('Liste concise des raisons.'), excerpts: z .array( z.object({ @@ -106,7 +147,7 @@ exports.analyseLyrics = async ({ title = '', lyrics }) => { severity: z.string(), }) ) - .max(10), + .max(3), success: z.boolean(), }) @@ -133,27 +174,31 @@ ${lyricsText} const startedAt = Date.now() try { - const { output } = await ai.generate({ - model: googleAI.model(TEXT_MODEL_NAME), - system, - prompt: userPrompt, - output: { schema: moderationSchema }, - config: { - // Paramètres de sécurité permissifs pour laisser l'IA voir et juger le contenu - safetySettings: [ - { category: 'HARM_CATEGORY_HATE_SPEECH', threshold: 'BLOCK_NONE' }, - { - category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', - threshold: 'BLOCK_NONE', - }, - { - category: 'HARM_CATEGORY_DANGEROUS_CONTENT', - threshold: 'BLOCK_NONE', - }, - { category: 'HARM_CATEGORY_HARASSMENT', threshold: 'BLOCK_NONE' }, - ], + const { output } = await generateTextWithRetry( + ai, + { + system, + prompt: userPrompt, + output: { schema: moderationSchema }, + config: { + temperature: 0, + // Paramètres de sécurité permissifs pour laisser l'IA voir et juger le contenu + safetySettings: [ + { category: 'HARM_CATEGORY_HATE_SPEECH', threshold: 'BLOCK_NONE' }, + { + category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', + threshold: 'BLOCK_NONE', + }, + { + category: 'HARM_CATEGORY_DANGEROUS_CONTENT', + threshold: 'BLOCK_NONE', + }, + { category: 'HARM_CATEGORY_HARASSMENT', threshold: 'BLOCK_NONE' }, + ], + }, }, - }) + 'analyseLyrics' + ) if (!output) throw new Error("Échec de l'analyse de modération.") diff --git a/functions/src/cover.js b/functions/src/cover.js index 2dc8769..b640493 100644 --- a/functions/src/cover.js +++ b/functions/src/cover.js @@ -92,9 +92,13 @@ const buildCircularMask = (size) => { async function loadCoverIdentity(project) { const artistName = normalizeArtistName(project.userName) const userId = typeof project.userId === 'string' ? project.userId.trim() : '' + const centerImageURL = + typeof project.coverCenterImageURL === 'string' && project.coverCenterImageURL.trim() + ? project.coverCenterImageURL.trim() + : null if (!userId) { - return { artistName, profilePictureURL: null } + return { artistName, profilePictureURL: centerImageURL } } try { @@ -102,14 +106,14 @@ async function loadCoverIdentity(project) { const userData = userSnapshot.exists ? userSnapshot.data() : null return { artistName: normalizeArtistName(project.userName || userData?.userName), - profilePictureURL: userData?.profilePictureURL || null, + profilePictureURL: centerImageURL || userData?.profilePictureURL || null, } } catch (error) { logger.warn('[Cover] Unable to load profile picture', { projectId: project.id, error: error.message, }) - return { artistName, profilePictureURL: null } + return { artistName, profilePictureURL: centerImageURL } } } diff --git a/functions/src/heygen.js b/functions/src/heygen.js index 292e844..cc81a04 100644 --- a/functions/src/heygen.js +++ b/functions/src/heygen.js @@ -17,6 +17,7 @@ const projectsRef = db.collection('projects') const REGION = process.env.FIREBASE_REGION || 'europe-west1' const HEYGEN_BASE_URL = 'https://api.heygen.com/v3' +const HEYGEN_REQUEST_TIMEOUT_MS = 45_000 const PLAYBACK_STATUS = { GENERATING: 'GENERATING', DRAFT_READY: 'DRAFT_READY', @@ -65,9 +66,10 @@ const buildPlaybackReadyNotificationId = ({ projectId, videoId }) => { return `playback-ready-${digest}` } -const getHeygenHeaders = () => ({ +const getHeygenHeaders = (idempotencyKey = null) => ({ 'Content-Type': 'application/json', 'X-Api-Key': getSecretValue(HEYGEN_API_KEY, 'HEYGEN_API_KEY'), + ...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}), }) const getProjectSnapshot = async ({ projectId, uid }) => { @@ -120,6 +122,7 @@ const findProjectForWebhook = async ({ callbackId, videoId }) => { const fetchCanonicalVideo = async (videoId) => { const response = await axios.get(`${HEYGEN_BASE_URL}/videos/${videoId}`, { headers: getHeygenHeaders(), + timeout: HEYGEN_REQUEST_TIMEOUT_MS, }) return response?.data?.data || null @@ -210,7 +213,8 @@ exports.startPlaybackGeneration = onCall( callback_id: callbackId, }, { - headers: getHeygenHeaders(), + headers: getHeygenHeaders(callbackId), + timeout: HEYGEN_REQUEST_TIMEOUT_MS, } ) diff --git a/functions/src/lyrics.js b/functions/src/lyrics.js index 3f16d19..91bd1e4 100644 --- a/functions/src/lyrics.js +++ b/functions/src/lyrics.js @@ -5,7 +5,7 @@ const { SUNO_API_BASE, SUNO_TIMESTAMPED_LYRICS_PATH } = require('../config/suno' const { GEMINI_API_KEY, SUNO_API_KEY } = require('../config/secrets') const axios = require('axios') const { FieldValue } = require('firebase-admin/firestore') -const { REGION, refList } = require('../index') +const { refList } = require('../index') // --- CONSTANTES DE STRUCTURE --- // On garde ces mappings car ils sont utiles pour normaliser l'input utilisateur @@ -59,13 +59,22 @@ const STRUCTURE_ALIASES = { soft_transition: 'transition_douce', } -const LYRICS_FUNCTION_OPTIONS = { - region: REGION, +const LYRICS_FUNCTION_BASE_OPTIONS = { cors: true, invoker: 'public', secrets: [GEMINI_API_KEY], } +const LYRICS_GENERATION_OPTIONS = { + ...LYRICS_FUNCTION_BASE_OPTIONS, + region: 'us-central1', +} + +const LYRICS_MODERATION_OPTIONS = { + ...LYRICS_FUNCTION_BASE_OPTIONS, + region: ['us-central1', 'europe-west1'], +} + // --- UTILITAIRES --- const normalizeStructureValue = (value) => { @@ -144,7 +153,7 @@ const buildModerationBrief = ({ // --- GENERATION DE PAROLES (MAIN) --- -exports.generateLyrics = onCall(LYRICS_FUNCTION_OPTIONS, async ({ auth = {}, data = {} }) => { +exports.generateLyrics = onCall(LYRICS_GENERATION_OPTIONS, async ({ auth = {}, data = {} }) => { try { const { lang: rawLang, @@ -204,9 +213,8 @@ exports.generateLyrics = onCall(LYRICS_FUNCTION_OPTIONS, async ({ auth = {}, dat } } catch (moderationError) { if (moderationError instanceof HttpsError) throw moderationError - console.error('⚠️ Moderation check error (fail open)', moderationError) - // On continue si l'appel modération fail (fail open) ou on throw (fail closed) selon ta politique. - // Ici je fail closed par sécurité pour une app publique. + console.error('⚠️ Moderation check error (fail closed)', moderationError) + // En cas d'indisponibilité du pré-contrôle, on bloque la génération par sécurité. throw new HttpsError('internal', 'Vérification de sécurité indisponible.') } @@ -364,7 +372,7 @@ const softenModerationDecision = (rawResult = {}) => { } } -exports.analyseLyricsToxicity = onCall(LYRICS_FUNCTION_OPTIONS, async ({ data = {} }) => { +exports.analyseLyricsToxicity = onCall(LYRICS_MODERATION_OPTIONS, async ({ data = {} }) => { try { const requestSchema = z.object({ title: z.string().optional(), @@ -440,7 +448,7 @@ async function getSunoTimestamps(projectId) { { headers: { 'Content-Type': 'application/json', - Authorization: `Bearer ${SUNO_API_KEY.value()}`, + Authorization: `Bearer ${SUNO_API_KEY.value().trim()}`, }, timeout: 30000, } diff --git a/functions/src/music.js b/functions/src/music.js index 530364d..e82ff68 100644 --- a/functions/src/music.js +++ b/functions/src/music.js @@ -2,7 +2,7 @@ const { onCall, onRequest, HttpsError } = require('firebase-functions/v2/https') const axios = require('axios') const admin = require('firebase-admin') const { FieldValue } = require('firebase-admin/firestore') -const { logger } = require('firebase-functions/logger') +const logger = require('firebase-functions/logger') const { pipeline } = require('stream/promises') const { randomUUID } = require('crypto') const { ALERT_TYPE, refList } = require('../index') @@ -516,8 +516,8 @@ exports.generateMusic = onCall({ secrets: [SUNO_API_KEY] }, async ({ data = {} } // 3. Détection du genre vocal pour le param vocalGender (optimisation v3.5) // On regarde si on trouve 'male' ou 'female' dans les tags générés let vGender = null - if (optimizedStyle.includes('Female')) vGender = 'female' - else if (optimizedStyle.includes('Male')) vGender = 'male' + if (optimizedStyle.includes('Female')) vGender = 'f' + else if (optimizedStyle.includes('Male')) vGender = 'm' console.log('🎵 [Suno Optimisation] Result:', { style: optimizedStyle, @@ -542,14 +542,36 @@ exports.generateMusic = onCall({ secrets: [SUNO_API_KEY] }, async ({ data = {} } const response = await axios.post(`${SUNO_API_BASE}${SUNO_API_PATH}`, payload, { headers: { 'Content-Type': 'application/json', - Authorization: `Bearer ${SUNO_API_KEY.value()}`, + Authorization: `Bearer ${SUNO_API_KEY.value().trim()}`, }, }) const parsed = response.data + if (parsed?.code !== 200) { + throw new Error(parsed?.msg || 'Suno a refusé la demande de génération.') + } + const taskId = sanitizeField(parsed?.data?.taskId) + + if (!taskId) { + throw new Error("Suno n'a pas retourné d'identifiant de génération.") + } + + const projectId = sanitizeField(data?.projectId) + if (projectId) { + await refList.projects.doc(projectId).set( + { + sunoTaskId: taskId, + musicStatus: 'GENERATING', + musicError: FieldValue.delete(), + generationStartAt: FieldValue.serverTimestamp(), + updatedAt: FieldValue.serverTimestamp(), + }, + { merge: true } + ) + } return { - success: !!parsed?.data?.taskId, + success: true, request: { model: payload.model, title: safeTitle, @@ -570,19 +592,94 @@ exports.generateMusic = onCall({ secrets: [SUNO_API_KEY] }, async ({ data = {} } } }) -// GET STATUS (inchangé mais inclus pour complétude) +const reconcileSunoStatus = async ({ taskId, statusData }) => { + const status = statusData?.status + const failedStatuses = new Set([ + 'CREATE_TASK_FAILED', + 'GENERATE_AUDIO_FAILED', + 'CALLBACK_EXCEPTION', + 'SENSITIVE_WORD_ERROR', + ]) + + if (status === 'SUCCESS') { + const tracks = Array.isArray(statusData?.response?.sunoData) + ? statusData.response.sunoData + : [] + const audioUrls = tracks + .map((track) => track?.audioUrl) + .filter((url) => typeof url === 'string' && url.trim()) + .slice(0, 2) + + if (!audioUrls.length) throw new Error('SUNO_AUDIO_URLS_MISSING') + + const { projectId, projectData, projectRef } = await fetchProjectByTaskId(taskId) + const existingUrls = sanitizeMusicUrls(projectData?.musicUrls) + if (projectData?.musicStatus === 'GENERATED' && existingUrls.length) { + return { reconciled: true, projectId, musicUrls: existingUrls } + } + + const { userId, projectTitle } = formatProjectMeta(projectData) + const storedUrls = await saveTracksToStorage(audioUrls, { + userId, + projectId, + taskId, + }) + if (!storedUrls.length) throw new Error('SUNO_AUDIO_STORAGE_FAILED') + + const musicUrls = await mergeMusicUrls(projectRef, storedUrls) + if (userId) { + try { + await sendNotification({ + sender: 'SYSTEM', + receiver: userId, + receiverCollection: 'users', + title: 'Musique prête', + message: `Ta musique pour "${projectTitle}" est prête.`, + data: { + type: ALERT_TYPE?.MUSIC_GENERATION_SUCCESS, + projectId, + projectTitle, + musicUrls, + taskId, + }, + }) + } catch (error) { + console.error('Music status notification failed', error) + } + } + return { reconciled: true, projectId, musicUrls } + } + + if (failedStatuses.has(status)) { + const { projectId } = await fetchProjectByTaskId(taskId) + return { + reconciled: false, + failed: true, + projectId, + musicUrls: [], + errorMessage: statusData?.errorMessage || 'La génération Suno a échoué.', + } + } + + return { reconciled: false, failed: false, projectId: null, musicUrls: [] } +} + +// GET STATUS — secours lorsque le callback Suno n'arrive pas. exports.getSunoStatus = onCall({ secrets: [SUNO_API_KEY] }, async ({ data = {} }) => { const { taskId } = data if (!taskId) throw new Error('TaskId manquant') try { const response = await axios.get(`${SUNO_API_BASE}${SUNO_STATUS_PATH}?taskId=${taskId}`, { - headers: { Authorization: `Bearer ${SUNO_API_KEY.value()}` }, + headers: { Authorization: `Bearer ${SUNO_API_KEY.value().trim()}` }, timeout: 30000, }) + const statusData = response.data?.data + const reconciliation = await reconcileSunoStatus({ taskId, statusData }) return { success: true, taskId, - data: response.data?.data || response.data, + status: statusData?.status || null, + ...reconciliation, } } catch (error) { if (taskId.includes('test')) diff --git a/functions/src/users.js b/functions/src/users.js index 22d633a..334c661 100644 --- a/functions/src/users.js +++ b/functions/src/users.js @@ -5,7 +5,7 @@ const { refList } = require('../index') const { ORDER_TYPES, createOrderDocument } = require('./helpers/orders') const { deleteFolder } = require('../helpers/firebase') const { Resend } = require('resend') -const { welcomeTemplate } = require('../helpers/email') +const { welcomeEmailSubject, welcomeTemplate } = require('../helpers/email') const { RESEND_API_KEY } = require('../config/secrets') const { onRequest } = require('firebase-functions/https') @@ -19,7 +19,6 @@ const getResendClient = () => { } const WELCOME_EMAIL_FROM = 'MusicLand ' -const WELCOME_EMAIL_SUBJECT = 'Bienvenue sur MusicLand' const USER_CREATED_OPTIONS = { document: 'users/{userID}', secrets: [RESEND_API_KEY], @@ -33,12 +32,12 @@ exports.testWelcomMail = onRequest({ secrets: [RESEND_API_KEY] }, async (req, re try { const targetEmail = req.query.email || 'tdtomthomas@gmail.com' const firstName = req.query.firstName || 'Toto' - const lastName = req.query.lastName || 'Test' + const preferredLanguage = req.query.preferredLanguage === 'en' ? 'en' : 'fr' const { data, error } = await getResendClient().emails.send({ from: WELCOME_EMAIL_FROM, to: [targetEmail], - subject: WELCOME_EMAIL_SUBJECT, - html: welcomeTemplate({ firstName, lastName }), + subject: welcomeEmailSubject({ firstName, preferredLanguage }), + html: welcomeTemplate({ firstName, preferredLanguage }), }) if (error) { console.log('Failed to send welcome email:', error) @@ -54,7 +53,7 @@ exports.testWelcomMail = onRequest({ secrets: [RESEND_API_KEY] }, async (req, re exports.onUserCreated = onDocumentCreated(USER_CREATED_OPTIONS, async (event) => { try { - const { email = '', firstName = '', lastName = '' } = event?.data?.data() || {} + const { email = '', firstName = '', preferredLanguage = 'fr' } = event?.data?.data() || {} const userId = event?.params?.userID @@ -79,8 +78,8 @@ exports.onUserCreated = onDocumentCreated(USER_CREATED_OPTIONS, async (event) => const { data, error } = await getResendClient().emails.send({ from: WELCOME_EMAIL_FROM, to: [email], - subject: WELCOME_EMAIL_SUBJECT, - html: welcomeTemplate({ firstName, lastName }), + subject: welcomeEmailSubject({ firstName, preferredLanguage }), + html: welcomeTemplate({ firstName, preferredLanguage }), }) if (error) { console.log('Failed to send welcome email:', error) 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 2cc114a..d5e564b 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 c4ad0c5..676557b 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 = USE_FUNCTIONS_EMULATOR && instance?.useEmulator diff --git a/src/hooks/useSocialAuth.js b/src/hooks/useSocialAuth.js index 204b2a1..8116192 100644 --- a/src/hooks/useSocialAuth.js +++ b/src/hooks/useSocialAuth.js @@ -88,13 +88,14 @@ const ensureUserDocument = async (user, overrides = {}, isNewUser = false) => { payload.createdAt = firebase.firestore.FieldValue.serverTimestamp() if (user.email) payload.email = user.email payload.emailNotifications = true - if (isNewUser) { - payload.registrationFlowRequired = true - } } else if (typeof existingData?.emailNotifications === 'undefined') { payload.emailNotifications = true } + if (isNewUser) { + payload.registrationFlowRequired = false + } + const overrideFirstName = typeof overrides?.firstName === 'string' ? overrides.firstName.trim() : null const overrideLastName = 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 5593784..22f2bff 100644 --- a/src/screens/CreatePassword.js +++ b/src/screens/CreatePassword.js @@ -12,10 +12,10 @@ import { checkIfPasswordIsStrongEnough, handleFirebaseError } from '../actions/s import firebase, { usersRef } from '../config/firebase' import Page from '../layouts/Page' import { isWeb } from '../hooks/useLayoutType' -import { Routes } from '../navigation' import { reset } from '../navigation/NavigationService' import { Palette } from '../styles' import { FONT_FAMILY } from '../styles/Fonts' +import { getMainMenuReset } from '../utils/registrationFlow' const CreatePassword = () => { const route = useRoute() @@ -115,14 +115,14 @@ const CreatePassword = () => { ...(birthDate ? { birthDate } : {}), ...(country?.code ? { countryCode: country.code } : {}), ...(country?.name ? { countryName: country.name } : {}), - registrationFlowRequired: true, + registrationFlowRequired: false, createdAt: firebase.firestore.FieldValue.serverTimestamp(), updatedAt: firebase.firestore.FieldValue.serverTimestamp(), }, { merge: true } ) - setTooltip({ text: 'Compte créé, continuons', type: 'success' }) - reset({ index: 0, routes: [{ name: Routes.RegistrationChoice }] }) + setTooltip({ text: 'Compte créé', type: 'success' }) + reset(getMainMenuReset()) } catch (e) { console.log('Register error', e?.message) const message = @@ -138,7 +138,7 @@ const CreatePassword = () => { return ( diff --git a/src/screens/HitParade/HitParade.js b/src/screens/HitParade/HitParade.js index da0749e..b7512ac 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 = [ @@ -451,11 +451,25 @@ const HitParade = () => { }) }, [navigateToMusicDetails, songsList]) - const heroTitle = isWeb - ? 'Coups de coeur des utilisateurs' - : `Coups de coeur des utilisateurs catégorie ${selectedCategory}` - - const heroSubtitle = 'Les 3 musiques les plus écoutées du mois reçoivent des tokens.' + const heroContent = { + Chansons: { + title: 'Nouveautés Streaming', + subtitle: 'Découvre les morceaux publiés par la communauté MusicLand.', + }, + 'Top du mois': { + title: 'Les plus écoutés ce mois-ci', + subtitle: 'Ce classement est basé sur le nombre d’écoutes du mois en cours.', + }, + 'All Time': { + title: 'Les plus écoutés de tous les temps', + subtitle: 'Ce classement est basé sur le nombre total d’écoutes.', + }, + } + const activeHeroContent = heroContent[selectedCategory] || heroContent.Chansons + const heroTitle = isWeb ? 'Streaming : nouveautés et classements' : activeHeroContent.title + const heroSubtitle = isWeb + ? 'Découvre les nouveautés, le Top du mois et les titres les plus écoutés de tous les temps.' + : activeHeroContent.subtitle const renderHero = () => ( @@ -666,11 +680,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 @@ -324,7 +330,9 @@ const Home = ({ navigation, route }) => { const songwriterAction = useMemo(() => getStageAction('songwriter', null), []) const handleStartNew = useCallback(async () => { - const targetRoute = songwriterAction?.route || Routes.CreateLyricsWithAi + const targetRoute = isWeb + ? songwriterAction?.route || Routes.CreateLyricsWithAi + : Routes.WritingLyrics const targetParams = songwriterAction?.params try { @@ -400,9 +408,13 @@ const Home = ({ navigation, route }) => { () => STAGE_CARD_CONTENT.map((card) => ({ ...card, + description: + typeof card.description === 'string' + ? card.description + : card.description[preferredLanguage], isLocked: stageStatesByKey[card.key]?.isLocked ?? true, })), - [stageStatesByKey] + [preferredLanguage, stageStatesByKey] ) const handleStagePress = useCallback( @@ -423,8 +435,12 @@ const Home = ({ navigation, route }) => { if (!action?.route) { return } + const targetRoute = + !isWeb && stageKey === 'songwriter' && action.route === Routes.CreateLyricsWithAi + ? Routes.WritingLyrics + : action.route console.log("action route is ", action.route) - navigate(action.route, action.params) + navigate(targetRoute, action.params) }, [currentProject, ensureProjectSelected, hasActiveProject, handleStartNew] ) @@ -448,6 +464,7 @@ const Home = ({ navigation, route }) => { step={card.step} description={card.description} image={card.image} + imageStyle={card.imageStyle} imagePosition={card.imagePosition} textAlign={card.textAlign} isLocked={card.isLocked} 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 @@ -61,14 +74,6 @@ const Playback = ({ route, navigation }) => { - ) : null - } containerStyle={isWeb ? styles.videoPage : undefined} > {/* */} @@ -103,6 +108,13 @@ const Playback = ({ route, navigation }) => { {/* */} + setVideoUrl(null)} + contentFit={isWeb ? 'contain' : 'cover'} + skipLabel={preferredLanguage === 'en' ? 'Skip video' : 'Passer la vidéo'} + /> ) } @@ -114,10 +126,6 @@ const styles = StyleSheet.create({ alignSelf: 'flex-start', marginLeft: '4%', }, - rightSideControl: { - right: 24, - left: 'auto', - }, blockedText: { color: Palette.white, opacity: 0.75, diff --git a/src/screens/Playback/PlaybackAi.js b/src/screens/Playback/PlaybackAi.js index 75d57df..f1e6986 100644 --- a/src/screens/Playback/PlaybackAi.js +++ b/src/screens/Playback/PlaybackAi.js @@ -1,7 +1,7 @@ import * as ImagePicker from 'expo-image-picker' import useMinuit from 'react-native-minuit/src/hooks/useMinuit.js' import React, { useMemo, useState } from 'react' -import { Image, Text, View } from 'react-native' +import { Image, Platform, Text, View } from 'react-native' import { background } from '../../assets' import ActivityLoader from '../../components/ActivityLoader' import BorderGradientButton from '../../components/BorderGradientButton' @@ -108,7 +108,7 @@ const PlaybackAi = ({ route }) => { throw new Error("Impossible d'envoyer la photo vers Firebase") } - await callStartPlaybackGeneration({ + const generationRequest = callStartPlaybackGeneration({ projectId: project.id, photoUrl, }) @@ -121,6 +121,8 @@ const PlaybackAi = ({ route }) => { playbackPhotoUrl: photoUrl, }, }) + + await generationRequest } catch (error) { setTooltip({ type: 'error', @@ -133,7 +135,7 @@ const PlaybackAi = ({ route }) => { return ( 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,25 +23,36 @@ const Playbacks = () => { const initialFocusProjectId = route?.params?.projectId || route?.params?.focusId || null const [focusProjectId, setFocusProjectId] = useState(initialFocusProjectId) const [viewMode, setViewMode] = useState(initialFocusProjectId ? 'feed' : 'charts') - const { data: rawPlaybacks = [], loadMore } = useDataFromRef({ + const [playbackType, setPlaybackType] = useState(PLAYBACK_TYPE.LIVE) + const { + data: rawPlaybacks = [], + loadMore, + hasMore, + loading, + } = useDataFromRef({ ref: projectsRef.where('playbackUrl', '!=', null), simpleRef: false, listener: false, usePagination: true, batchSize: 6, }) + const filteredPlaybacks = useMemo( + () => rawPlaybacks.filter((item) => matchesPlaybackType(item, playbackType)), + [playbackType, rawPlaybacks] + ) + const focusIndex = useMemo(() => { if (!focusProjectId) return -1 - return rawPlaybacks.findIndex((p) => p?.id === focusProjectId) - }, [focusProjectId, rawPlaybacks]) + return filteredPlaybacks.findIndex((p) => p?.id === focusProjectId) + }, [filteredPlaybacks, focusProjectId]) const playbacks = useMemo(() => { - if (focusIndex < 0) return rawPlaybacks - const target = rawPlaybacks[focusIndex] - const before = rawPlaybacks.slice(0, focusIndex) - const after = rawPlaybacks.slice(focusIndex + 1) + if (focusIndex < 0) return filteredPlaybacks + const target = filteredPlaybacks[focusIndex] + const before = filteredPlaybacks.slice(0, focusIndex) + const after = filteredPlaybacks.slice(focusIndex + 1) return [target, ...after, ...before] - }, [focusIndex, rawPlaybacks]) + }, [filteredPlaybacks, focusIndex]) const onSnap = useCallback( (index) => { @@ -63,6 +76,14 @@ const Playbacks = () => { } }, [focusProjectId]) + useEffect(() => { + if (!focusProjectId) return + const focusedPlayback = rawPlaybacks.find((item) => item?.id === focusProjectId) + if (focusedPlayback) { + setPlaybackType(getPlaybackType(focusedPlayback)) + } + }, [focusProjectId, rawPlaybacks]) + useEffect(() => { if (loadMore && triedLoadMoreRef.current < 6) { triedLoadMoreRef.current += 1 @@ -70,6 +91,12 @@ const Playbacks = () => { } }, [loadMore, playbacks]) + useEffect(() => { + if (playbacks.length === 0 && hasMore && !loading) { + loadMore?.() + } + }, [hasMore, loadMore, loading, playbacks.length]) + useEffect(() => { if (!focusProjectId) return if (focusIndex >= 0 && !hasAppliedFocusRef.current) { @@ -107,6 +134,12 @@ const Playbacks = () => { const showFeed = viewMode === 'feed' + const handlePlaybackTypeChange = useCallback((nextType) => { + setFocusProjectId(null) + setPlaybackType(nextType) + setActiveIndex(0) + }, []) + const openFeedForPlayback = useCallback((project) => { const nextId = project && typeof project === 'object' @@ -115,17 +148,27 @@ const Playbacks = () => { if (!nextId) { return } + if (project && typeof project === 'object') { + setPlaybackType(getPlaybackType(project)) + } setFocusProjectId(nextId) setViewMode('feed') }, []) if (!showFeed) { - return + 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 + + ) } @@ -156,10 +213,18 @@ const Playbacks = () => { export default Playbacks const styles = StyleSheet.create({ - backToCharts: { + feedHeader: { position: 'absolute', top: 32, - right: 20, + left: 16, + right: 16, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: 8, + zIndex: 20, + }, + backToCharts: { paddingVertical: 8, paddingHorizontal: 12, borderRadius: 12, @@ -171,4 +236,16 @@ const styles = StyleSheet.create({ fontSize: 12, fontFamily: FONT_FAMILY.InterSemiBold, }, + emptyState: { + ...StyleSheet.absoluteFillObject, + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: 24, + }, + emptyStateText: { + color: Palette.white, + fontSize: 15, + fontFamily: FONT_FAMILY.InterMedium, + textAlign: 'center', + }, }) diff --git a/src/screens/Playbacks/Playbacks.web.js b/src/screens/Playbacks/Playbacks.web.js index 8f80dec..183c5c5 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() @@ -43,18 +46,23 @@ const Playbacks = () => { usePagination: true, batchSize: 6, }) + const filteredPlaybacks = useMemo( + () => rawPlaybacks.filter((item) => matchesPlaybackType(item, playbackType)), + [playbackType, rawPlaybacks] + ) + const focusIndex = useMemo(() => { if (!focusProjectId) return -1 - return rawPlaybacks.findIndex((p) => p?.id === focusProjectId) - }, [focusProjectId, rawPlaybacks]) + return filteredPlaybacks.findIndex((p) => p?.id === focusProjectId) + }, [filteredPlaybacks, focusProjectId]) const playbacks = useMemo(() => { - if (focusIndex < 0) return rawPlaybacks - const target = rawPlaybacks[focusIndex] - const before = rawPlaybacks.slice(0, focusIndex) - const after = rawPlaybacks.slice(focusIndex + 1) + if (focusIndex < 0) return filteredPlaybacks + const target = filteredPlaybacks[focusIndex] + const before = filteredPlaybacks.slice(0, focusIndex) + const after = filteredPlaybacks.slice(focusIndex + 1) return [target, ...after, ...before] - }, [focusIndex, rawPlaybacks]) + }, [filteredPlaybacks, focusIndex]) const activePlayback = playbacks?.[activeIndex] || null const activeVideoUrl = activePlayback?.playbackUrl || null @@ -187,6 +195,14 @@ const Playbacks = () => { focusLoadAttemptsRef.current = 0 }, [focusProjectId]) + useEffect(() => { + if (!focusProjectId) return + const focusedPlayback = rawPlaybacks.find((item) => item?.id === focusProjectId) + if (focusedPlayback) { + setPlaybackType(getPlaybackType(focusedPlayback)) + } + }, [focusProjectId, rawPlaybacks]) + useEffect(() => { if (!focusProjectId) return if (focusIndex >= 0 && !hasAppliedFocusRef.current) { @@ -206,6 +222,12 @@ const Playbacks = () => { } }, [focusIndex, focusProjectId, loadMore, playbacks]) + useEffect(() => { + if (playbacks.length === 0 && hasMore && !loading) { + loadMore?.() + } + }, [hasMore, loadMore, loading, playbacks.length]) + useEffect(() => { const nextFocusId = route?.params?.projectId || route?.params?.focusId || null setFocusProjectId(nextFocusId) @@ -334,6 +356,13 @@ const Playbacks = () => { const showFeed = viewMode === 'feed' + const handlePlaybackTypeChange = useCallback((nextType) => { + setFocusProjectId(null) + setPlaybackType(nextType) + lastActiveIndexRef.current = 0 + setActiveIndex(0) + }, []) + const openFeedForPlayback = useCallback((project) => { const nextId = project && typeof project === 'object' @@ -342,12 +371,21 @@ const Playbacks = () => { if (!nextId) { return } + if (project && typeof project === 'object') { + setPlaybackType(getPlaybackType(project)) + } setFocusProjectId(nextId) setViewMode('feed') }, []) if (!showFeed) { - return + return ( + + ) } return ( @@ -377,6 +415,7 @@ const Playbacks = () => { ) : null} String(item?.id || index)} @@ -401,6 +440,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 + + // ) @@ -487,6 +540,17 @@ const styles = StyleSheet.create({ ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(2, 2, 2, 0.35)', }, + feedHeader: { + position: 'absolute', + top: 28, + left: 24, + right: 24, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: 12, + zIndex: 20, + }, topOverlayContainer: { position: 'absolute', top: 32, @@ -571,9 +635,6 @@ const styles = StyleSheet.create({ textAlign: 'center', }, backToCharts: { - position: 'absolute', - top: 28, - right: 24, paddingVertical: 10, paddingHorizontal: 14, borderRadius: 999, @@ -595,4 +656,16 @@ const styles = StyleSheet.create({ fontSize: 13, fontFamily: FONT_FAMILY.InterSemiBold, }, + emptyState: { + ...StyleSheet.absoluteFillObject, + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: 24, + }, + emptyStateText: { + color: Palette.white, + fontSize: 15, + fontFamily: FONT_FAMILY.InterMedium, + textAlign: 'center', + }, }) diff --git a/src/screens/Playbacks/components/PlaybackCharts.js b/src/screens/Playbacks/components/PlaybackCharts.js index 8eb7297..830375f 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 { @@ -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 b54c7b7..a2e998c 100644 --- a/src/screens/Production/PlaybackDownload.js +++ b/src/screens/Production/PlaybackDownload.js @@ -13,7 +13,7 @@ import firebase, { getFunctionsClient, projectsRef, serverTimestamp } from '../. import { uploadFileToFirebase } from '../../helpers/uploadToFirebase' import Page from '../../layouts/Page' import { Routes } from '../../navigation' -import { goBack, navigate } from '../../navigation/NavigationService' +import { navigate } from '../../navigation/NavigationService' import { useUserData, useUser } from '../../providers/UserDataProvider' import { useStripe } from '../../providers/StripeProvider' import { Palette } from '../../styles' @@ -391,6 +391,15 @@ const PlaybackDownload = ({ route }) => { handleDownloadUri() }, [downloadPaymentPending, handleDownloadUri, hasPaidPlaybackDownload, isDownloading]) + const handleGoHome = useCallback(() => { + navigate(Routes.BottomTab, { + screen: Routes.HomeStack, + params: { + screen: Routes.Home, + }, + }) + }, []) + const handlePublish = async () => { if (!hasAcceptedPublication) return @@ -402,6 +411,7 @@ const PlaybackDownload = ({ route }) => { await projectsRef.doc(projectForDownload.id).set( { playbackUrl: url, + playbackProvider: 'live', updatedAt: serverTimestamp(), }, { merge: true } @@ -431,7 +441,7 @@ const PlaybackDownload = ({ route }) => { backgroundColor="#07111B" headerType="NONE" > - + @@ -494,6 +504,12 @@ const PlaybackDownload = ({ route }) => { loadingText="Publication en cours..." containerStyle={styles.continueButton} /> + @@ -562,4 +578,8 @@ const styles = StyleSheet.create({ continueButton: { marginTop: 10, }, + homeButton: { + marginTop: 2, + marginBottom: 18, + }, }) diff --git a/src/screens/Register.js b/src/screens/Register.js index 0db6985..9d2ffa2 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' @@ -130,13 +130,6 @@ const Register = ({ navigation }) => { }) } - const onDateChange = (event, selectedDate) => { - setShowDatePicker(Platform.OS === 'ios') - if (selectedDate) { - setBirthDate(selectedDate) - } - } - const renderFormContent = () => ( @@ -200,17 +193,13 @@ const Register = ({ navigation }) => { /> - {showDatePicker && ( - - )} + setShowDatePicker(false)} + onValidate={setBirthDate} + initialDate={birthDate} + language={preferredLanguage} + /> )} @@ -248,7 +237,7 @@ const Register = ({ navigation }) => { { return ( diff --git a/src/screens/Studio/ComposeSong.js b/src/screens/Studio/ComposeSong.js index 465d7f7..6f90667 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 @@ -294,13 +291,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 776d85e..bcbe701 100644 --- a/src/screens/Studio/SongReady.js +++ b/src/screens/Studio/SongReady.js @@ -122,6 +122,7 @@ const SongReady = () => { coverUrl: null, coverStatus: null, playbackUrl: null, + playbackProvider: null, playbackStatus: null, playbackGenerating: null, }) 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" - ] - } -}