feat: update onboarding and application flows
This commit is contained in:
@@ -32,7 +32,6 @@ import { LogBox } from 'react-native'
|
||||
import CommentsBottomSheet from './src/components/bottomsheets/CommentsBottomSheet'
|
||||
import ShareQrModalContainer from './src/components/modal/ShareQrModalContainer'
|
||||
import AppDownloadBanner from './src/components/AppDownloadBanner'
|
||||
import MobileSplashVideo from './src/components/MobileSplashVideo'
|
||||
import './src/utils/Sheet'
|
||||
|
||||
console.disableYellowBox = true
|
||||
@@ -208,7 +207,6 @@ const App = () => {
|
||||
<CommentsBottomSheet />
|
||||
<ShareQrModalContainer />
|
||||
<AppDownloadBanner />
|
||||
<MobileSplashVideo />
|
||||
{/* </AppLayout> */}
|
||||
</MenuProvider>
|
||||
</Providers>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@mipmap/ic_launcher_background"/>
|
||||
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
</adaptive-icon>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@mipmap/ic_launcher_background"/>
|
||||
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
</adaptive-icon>
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,82 @@
|
||||
# Chiffrage D-ID API (image + musique) - MusicLand
|
||||
|
||||
Date de référence: 11 février 2026
|
||||
Périmètre: API uniquement (pas Studio en usage manuel), génération vidéo à partir d'une image + audio musique.
|
||||
|
||||
## 1. Hypothèses retenues
|
||||
|
||||
- Durée musique utilisateur: 2:30 à 3:20.
|
||||
- Durée moyenne de calcul: 2:55.
|
||||
- Facturation D-ID (source pricing officielle): une vidéo est arrondie à la minute supérieure pour estimer la consommation.
|
||||
- Barème public visible (abonnement annuel):
|
||||
1. Lite: 4.7 USD/mois, 10 min/mois.
|
||||
2. Pro: 16 USD/mois, 15 min/mois.
|
||||
3. Advanced: 108 USD/mois, 100 min/mois.
|
||||
|
||||
Note usage commercial:
|
||||
- D-ID indique que les plans Pro/Advanced/Enterprise/Launch/Scale incluent l'usage commercial et la monétisation.
|
||||
- Trial/Lite/Build sont indiqués comme usage personnel.
|
||||
|
||||
## 2. Conversion durée -> minutes facturées
|
||||
|
||||
1. 2:30 (150s) -> 3 min facturées.
|
||||
2. 2:55 (175s) -> 3 min facturées.
|
||||
3. 3:20 (200s) -> 4 min facturées.
|
||||
|
||||
## 3. Coût par vidéo (API)
|
||||
|
||||
Formule:
|
||||
|
||||
```text
|
||||
Coût vidéo = (Prix mensuel du plan / minutes incluses mensuelles) x minutes facturées
|
||||
```
|
||||
|
||||
Coût minute estimé:
|
||||
|
||||
1. Lite: 0.47 USD/min
|
||||
2. Pro: 1.07 USD/min
|
||||
3. Advanced: 1.08 USD/min
|
||||
|
||||
Coût vidéo selon durée:
|
||||
|
||||
| Plan | 2:30 (3 min) | 2:55 (3 min) | 3:20 (4 min) |
|
||||
|---|---:|---:|---:|
|
||||
| Lite | 1.41 USD | 1.41 USD | 1.88 USD |
|
||||
| Pro | 3.20 USD | 3.20 USD | 4.27 USD |
|
||||
| Advanced | 3.24 USD | 3.24 USD | 4.32 USD |
|
||||
|
||||
## 4. Budget mensuel selon volume (moyenne 2:55)
|
||||
|
||||
Hypothèse moyenne: 3 min facturées par vidéo.
|
||||
|
||||
| Volume | Minutes facturées | Budget Lite | Budget Pro | Budget Advanced |
|
||||
|---|---:|---:|---:|---:|
|
||||
| 100 vidéos/mois | 300 min | 141 USD | 320 USD | 324 USD |
|
||||
| 500 vidéos/mois | 1500 min | 705 USD | 1600 USD | 1620 USD |
|
||||
| 1000 vidéos/mois | 3000 min | 1410 USD | 3200 USD | 3240 USD |
|
||||
|
||||
## 5. Lecture business rapide
|
||||
|
||||
1. Pour une app commerciale, il faut viser au minimum un plan avec droits commerciaux (Pro/Advanced/Launch/Scale selon ton contrat exact).
|
||||
2. À durée 2:30-3:20, ton coût API cible réaliste se situe autour de 3.2 à 4.3 USD/vidéo avec une structure Pro/Advanced.
|
||||
3. Le vrai coût final dépendra du plan exact visible dans ton dashboard D-ID API (Launch/Scale possibles), et des éventuelles limites/overages contractuels.
|
||||
|
||||
## 6. Formule de recalcul immédiat (si ton plan API diffère)
|
||||
|
||||
Si ton plan API affiche `P` USD/mois pour `M` minutes incluses:
|
||||
|
||||
```text
|
||||
Coût vidéo (durée d secondes) = (P / M) x ceil(d / 60)
|
||||
```
|
||||
|
||||
Exemple direct avec moyenne 2:55:
|
||||
|
||||
```text
|
||||
Coût vidéo moyen = (P / M) x 3
|
||||
```
|
||||
|
||||
## Sources
|
||||
|
||||
- D-ID Pricing Studio (FAQ: consommation API sur le même solde, arrondi minute): https://www.d-id.com/pricing/studio/
|
||||
- D-ID droits d'usage (commercial vs personnel + mention Launch/Scale): https://help.d-id.com/hc/en-us/articles/31350883819537-What-are-the-copyright-and-commercial-use-rights-for-my-videos
|
||||
- D-ID API credits endpoint: https://docs.d-id.com/reference/getcredits
|
||||
Binary file not shown.
@@ -0,0 +1,158 @@
|
||||
# Emails existants MusicLand
|
||||
|
||||
Ce document liste uniquement les emails déjà présents comme emails dans l'application.
|
||||
|
||||
Les notifications applicatives qui peuvent être transformées en emails via `notifications/{notificationId}` sont exclues volontairement.
|
||||
|
||||
## Synthèse
|
||||
|
||||
| # | Email | Service | Déclencheur | Destinataire | Contenu maîtrisé dans le code |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | Bienvenue | Resend | Création d'un utilisateur | Nouvel utilisateur | Oui |
|
||||
| 2 | Réinitialisation du mot de passe | Firebase Auth | Mot de passe oublié | Email saisi | Non, template Firebase |
|
||||
| 3 | Vérification de nouvelle adresse email | Firebase Auth | Changement d'adresse email | Nouvelle adresse | Non, template Firebase |
|
||||
|
||||
## 1. Email de bienvenue
|
||||
|
||||
**Service**
|
||||
Resend.
|
||||
|
||||
**Expéditeur**
|
||||
`MusicLand <musicland@musicland.ai>`
|
||||
|
||||
**Déclencheur**
|
||||
Création d'un document utilisateur `users/{userID}`.
|
||||
|
||||
**Pourquoi il est envoyé**
|
||||
Accueillir l'utilisateur après son inscription sur MusicLand.
|
||||
|
||||
**Destinataire**
|
||||
L'adresse stockée dans `users.email`.
|
||||
|
||||
**Sujet**
|
||||
`Bienvenue sur MusicLand`
|
||||
|
||||
**Contenu exact du template**
|
||||
|
||||
| Zone | Contenu |
|
||||
|---|---|
|
||||
| Logo | Logo MusicLand en haut de l'email |
|
||||
| Salutation | `Salut {firstName} {lastName},` si le prénom ou le nom existent |
|
||||
| Salutation fallback | `Salut,` si aucun prénom/nom n'est disponible |
|
||||
| Titre principal | `Bienvenue sur MusicLand` |
|
||||
| Paragraphe 1 | `Nous sommes ravis de t'accueillir dans la communauté des artistes et passionnés de MusicLand.` |
|
||||
| Paragraphe 2 | `Explore les playlists collaboratives, dépose tes projets et découvre ce que prépare la scène émergente.` |
|
||||
| Signature | `L'équipe MusicLand` |
|
||||
| Footer | `minuit.app · Paris, France` |
|
||||
|
||||
**Exemple de contenu**
|
||||
|
||||
```text
|
||||
Salut Maya Durand,
|
||||
|
||||
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
|
||||
|
||||
minuit.app · Paris, France
|
||||
```
|
||||
|
||||
**Variables utilisées**
|
||||
|
||||
| Variable | Source | Usage |
|
||||
|---|---|---|
|
||||
| `email` | Document utilisateur | Destinataire |
|
||||
| `firstName` | Document utilisateur | Salutation personnalisée |
|
||||
| `lastName` | Document utilisateur | Salutation personnalisée |
|
||||
|
||||
**Fichiers source**
|
||||
|
||||
- `functions/src/users.js`
|
||||
- `functions/helpers/email.js`
|
||||
|
||||
## 2. Email de réinitialisation du mot de passe
|
||||
|
||||
**Service**
|
||||
Firebase Auth.
|
||||
|
||||
**Déclencheur**
|
||||
L'utilisateur renseigne son adresse email dans l'écran mot de passe oublié.
|
||||
|
||||
**Pourquoi il est envoyé**
|
||||
Permettre à l'utilisateur de réinitialiser son mot de passe.
|
||||
|
||||
**Destinataire**
|
||||
L'adresse email saisie par l'utilisateur.
|
||||
|
||||
**Sujet**
|
||||
Non défini dans le repo. Le sujet est géré par le template Firebase Auth du projet.
|
||||
|
||||
**Contenu**
|
||||
|
||||
Le contenu exact n'est pas dans le code de l'application. Il est configuré côté Firebase Auth.
|
||||
|
||||
Le code déclenche seulement :
|
||||
|
||||
```js
|
||||
firebase.auth().sendPasswordResetEmail(trimmedEmail)
|
||||
```
|
||||
|
||||
Le contenu attendu du template Firebase est :
|
||||
|
||||
- un message indiquant qu'une demande de réinitialisation a été faite ;
|
||||
- un lien de réinitialisation du mot de passe ;
|
||||
- les instructions standard définies dans Firebase ;
|
||||
- éventuellement le nom de l'application selon la configuration Firebase.
|
||||
|
||||
**Fichier source**
|
||||
|
||||
- `src/screens/ForgotPassword.js`
|
||||
|
||||
## 3. Email de vérification de nouvelle adresse email
|
||||
|
||||
**Service**
|
||||
Firebase Auth.
|
||||
|
||||
**Déclencheur**
|
||||
L'utilisateur demande à modifier son adresse email depuis son profil.
|
||||
|
||||
**Pourquoi il est envoyé**
|
||||
Vérifier la nouvelle adresse email avant de finaliser le changement.
|
||||
|
||||
**Destinataire**
|
||||
La nouvelle adresse email saisie par l'utilisateur.
|
||||
|
||||
**Sujet**
|
||||
Non défini dans le repo. Le sujet est géré par le template Firebase Auth du projet.
|
||||
|
||||
**Contenu**
|
||||
|
||||
Le contenu exact n'est pas dans le code de l'application. Il est configuré côté Firebase Auth.
|
||||
|
||||
Le code déclenche :
|
||||
|
||||
```js
|
||||
user.verifyBeforeUpdateEmail(val)
|
||||
```
|
||||
|
||||
Le contenu attendu du template Firebase est :
|
||||
|
||||
- un message indiquant qu'une modification d'adresse email a été demandée ;
|
||||
- un lien de vérification ;
|
||||
- les instructions standard définies dans Firebase ;
|
||||
- éventuellement le nom de l'application selon la configuration Firebase.
|
||||
|
||||
**Fichier source**
|
||||
|
||||
- `src/screens/Profile/ChangeEmailAddress.js`
|
||||
|
||||
## Point important
|
||||
|
||||
À partir du code du repo, le seul email dont le contenu HTML est entièrement maîtrisé dans l'application est l'email de bienvenue.
|
||||
|
||||
Les deux autres emails existent bien dans les parcours produit, mais leur sujet et leur contenu exact dépendent des templates configurés dans Firebase Auth.
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
# Roadmap Client - Playback IA MusicLand
|
||||
|
||||
## Vision
|
||||
|
||||
MusicLand doit permettre a chaque utilisateur d'interpreter sa propre chanson en playback, meme sans talent vocal, avec deux possibilites simples :
|
||||
|
||||
- se filmer en live ;
|
||||
- generer une video IA a partir d'une seule photo.
|
||||
|
||||
L'objectif de cette version est de renforcer la creation de contenu sur MusicLand et de pousser les utilisateurs a publier leurs playbacks sur la plateforme.
|
||||
|
||||
L'aspect evenement physique / IRL n'est pas prevu pour cette version.
|
||||
|
||||
Le duo n'est pas prevu pour cette version.
|
||||
|
||||
## Ce Qui Change Pour L'utilisateur
|
||||
|
||||
L'etape playback devient plus riche, mais le parcours global de l'application reste le meme.
|
||||
|
||||
L'utilisateur cree toujours sa chanson, puis arrive a l'etape playback. A ce moment-la, il peut choisir entre deux options :
|
||||
|
||||
1. **Me filmer en live**
|
||||
- L'utilisateur enregistre son playback avec sa camera.
|
||||
- Il gagne des credits.
|
||||
- Ce chemin est mis en avant, car il cree du contenu authentique pour la communaute.
|
||||
|
||||
2. **Generer mon playback avec IA**
|
||||
- L'utilisateur ajoute une seule photo.
|
||||
- Il decrit la scene souhaitee.
|
||||
- MusicLand genere une video ou il est reconnaissable et fait le lip-sync sur toute la chanson.
|
||||
- Ce chemin coute des credits.
|
||||
|
||||
Point important a afficher clairement dans l'interface :
|
||||
|
||||
```text
|
||||
Pour cette version, la video IA fonctionne avec une seule photo et une seule personne.
|
||||
Il n'est pas possible d'ajouter deux images ou de generer un duo.
|
||||
```
|
||||
|
||||
## Role Des Avatars
|
||||
|
||||
Les avatars restent au coeur de l'experience.
|
||||
|
||||
Ils doivent guider l'utilisateur avec des videos courtes, motivantes et faciles a passer :
|
||||
|
||||
- avant le playback : donner envie de se lancer ;
|
||||
- avant le live : conseiller sur le cadrage, la lumiere et le regard camera ;
|
||||
- avant l'IA : expliquer comment choisir une bonne photo et une scene simple ;
|
||||
- apres la creation : encourager la publication sur MusicLand.
|
||||
|
||||
Les avatars ne doivent pas ralentir le parcours. Ils doivent rassurer, conseiller et donner de l'energie.
|
||||
|
||||
## Flow Recommande
|
||||
|
||||
Le client souhaite garder une experience immersive, mais eviter les videos longues.
|
||||
|
||||
La recommandation est de remplacer les longues videos de transition par des interventions courtes et contextuelles.
|
||||
|
||||
### Principe
|
||||
|
||||
- pas de video longue obligatoire ;
|
||||
- pas de tunnel coupe par trop d'explications ;
|
||||
- des avatars visibles aux bons moments ;
|
||||
- des messages courts ;
|
||||
- une action claire a chaque ecran.
|
||||
|
||||
### Format recommande
|
||||
|
||||
1. **Micro-video avatar**
|
||||
- 5 a 10 secondes ;
|
||||
- skippable ;
|
||||
- sous-titree ;
|
||||
- lancee seulement aux moments importants.
|
||||
|
||||
2. **Conseil interactif**
|
||||
- une phrase courte de l'avatar ;
|
||||
- un bouton d'action direct ;
|
||||
- exemple : `Choisis une photo nette, visage face camera`.
|
||||
|
||||
3. **Aide secondaire**
|
||||
- les conseils detailles restent accessibles ;
|
||||
- ils ne bloquent pas l'enregistrement ou la generation.
|
||||
|
||||
### Flow cible
|
||||
|
||||
```text
|
||||
Choix Playback
|
||||
-> Live ou IA
|
||||
-> Conseil avatar court
|
||||
-> Creation
|
||||
-> Preview
|
||||
-> Publier sur MusicLand
|
||||
```
|
||||
|
||||
## Systeme De Credits Recommande
|
||||
|
||||
Il faut aligner le backend avec ce qui est affiche dans l'application.
|
||||
|
||||
Abonnements recommandes :
|
||||
|
||||
```text
|
||||
Pack Blue : 50 credits / mois
|
||||
Pack Silver : 110 credits / mois
|
||||
Pack Gold : 160 credits / mois
|
||||
```
|
||||
|
||||
Achats ponctuels :
|
||||
|
||||
```text
|
||||
30 credits = 4,99 EUR
|
||||
100 credits = 9,99 EUR
|
||||
150 credits = 14,99 EUR
|
||||
```
|
||||
|
||||
Couts recommandes :
|
||||
|
||||
```text
|
||||
Generation musique : 8 credits
|
||||
Playback live : +3 credits gagnes
|
||||
Playback IA : 60 credits
|
||||
Export prive : +20 credits ou reserve aux abonnes
|
||||
```
|
||||
|
||||
Le bonus live doit rester motivant, mais limite pour eviter les abus :
|
||||
|
||||
- 1 bonus live par projet ;
|
||||
- 1 a 2 bonus live maximum par jour ;
|
||||
- bonus donne uniquement apres creation reussie de la video.
|
||||
|
||||
## Playback IA
|
||||
|
||||
Pour la premiere version, le plus fiable est de faire une video simple :
|
||||
|
||||
- visage reconnaissable ;
|
||||
- une seule personne ;
|
||||
- une seule photo ;
|
||||
- plan portrait ou buste ;
|
||||
- regard camera ;
|
||||
- mouvement leger ;
|
||||
- lip-sync precis ;
|
||||
- duree de toute la chanson.
|
||||
|
||||
Il ne faut pas viser tout de suite une video avec danse, corps entier ou mise en scene complexe. Cela augmenterait le cout et le risque de mauvais resultat.
|
||||
|
||||
Il faut aussi exclure clairement :
|
||||
|
||||
- deux images ;
|
||||
- duo ;
|
||||
- groupe ;
|
||||
- celebrites ;
|
||||
- photo d'un tiers sans autorisation.
|
||||
|
||||
## Solutions IA Recommandees
|
||||
|
||||
Le besoin MusicLand est specifique : une photo, une chanson complete, un utilisateur reconnaissable, et un lip-sync precis.
|
||||
|
||||
### Recommandation principale : D-ID
|
||||
|
||||
D-ID est la meilleure option pour demarrer.
|
||||
|
||||
Pourquoi :
|
||||
|
||||
- adapte a une photo + audio ;
|
||||
- compatible avec une chanson complete ;
|
||||
- bon rapport simplicite / cout ;
|
||||
- moderation image et audio integree ;
|
||||
- delai annonce compatible avec une experience de quelques minutes.
|
||||
|
||||
Limite :
|
||||
|
||||
- rendu plutot portrait / talking head ;
|
||||
- moins adapte aux clips avec danse, corps entier ou scenes tres dynamiques.
|
||||
|
||||
### Option a tester en parallele : Sync Labs
|
||||
|
||||
Sync Labs est interessant si l'objectif prioritaire devient la qualite du lip-sync.
|
||||
|
||||
Pourquoi :
|
||||
|
||||
- specialise dans le lip-sync ;
|
||||
- accepte image ou video + audio ;
|
||||
- bon candidat pour comparer la precision bouche / audio.
|
||||
|
||||
Limite :
|
||||
|
||||
- cout plus eleve ;
|
||||
- necessite des tests qualite sur des chansons completes.
|
||||
|
||||
### Option premium : HeyGen
|
||||
|
||||
HeyGen peut etre interessant si le client veut un rendu avatar plus premium.
|
||||
|
||||
Pourquoi :
|
||||
|
||||
- rendu plus propre visuellement ;
|
||||
- ecosysteme avatar mature ;
|
||||
- bon pour videos avatar commerciales.
|
||||
|
||||
Limite :
|
||||
|
||||
- cout plus eleve sur une chanson complete ;
|
||||
- moins prioritaire pour un MVP MusicLand.
|
||||
|
||||
### Solutions non prioritaires pour cette V1
|
||||
|
||||
Les generateurs video generalistes, comme Sora ou autres modeles prompt-to-video, ne sont pas le bon choix principal pour cette version.
|
||||
|
||||
Raison :
|
||||
|
||||
- durees souvent trop courtes ;
|
||||
- lip-sync chanson complete plus difficile ;
|
||||
- controle de l'identite moins fiable ;
|
||||
- cout potentiellement trop eleve pour une fonctionnalite grand public.
|
||||
|
||||
## Point De Cadrage Video
|
||||
|
||||
Pour eviter les malentendus, l'ecran IA doit expliquer simplement la limite :
|
||||
|
||||
```text
|
||||
La generation IA utilise une seule photo.
|
||||
Pour cette version, les duos et les videos avec deux personnes ne sont pas encore disponibles.
|
||||
```
|
||||
|
||||
Cette limite est importante pour trois raisons :
|
||||
|
||||
- meilleur controle de la qualite ;
|
||||
- cout plus previsible ;
|
||||
- moins de risques de droits a l'image.
|
||||
|
||||
## Publication Sur MusicLand
|
||||
|
||||
La publication doit devenir le chemin principal.
|
||||
|
||||
Apres creation du playback, l'ecran doit pousser l'utilisateur vers :
|
||||
|
||||
```text
|
||||
Publier sur MusicLand et recuperer ma video HD
|
||||
```
|
||||
|
||||
Les autres options doivent rester secondaires :
|
||||
|
||||
```text
|
||||
Garder en brouillon
|
||||
Telecharger sans publier
|
||||
```
|
||||
|
||||
Le telechargement sans publication ne doit pas etre trop avantageux, sinon l'utilisateur repart avec son fichier sans enrichir la plateforme.
|
||||
|
||||
Recommandation :
|
||||
|
||||
- publication = video HD incluse ;
|
||||
- brouillon = conserve dans l'app ;
|
||||
- telechargement prive = payant ou reserve aux abonnes.
|
||||
|
||||
## Securite Et Consentement
|
||||
|
||||
Avant toute generation IA, il faut demander des engagements clairs :
|
||||
|
||||
- utilisateur majeur uniquement ;
|
||||
- consentement obligatoire ;
|
||||
- interdiction d'utiliser la photo d'une celebrite ;
|
||||
- interdiction d'utiliser la photo d'un tiers sans autorisation ;
|
||||
- possibilite de signaler une video ;
|
||||
- suppression possible sur demande.
|
||||
|
||||
## Roadmap De Livraison
|
||||
|
||||
### Lot 1 - Credits Et Regles Produit
|
||||
|
||||
Objectif : stabiliser le modele economique.
|
||||
|
||||
A livrer :
|
||||
|
||||
- aligner les credits des abonnements ;
|
||||
- figer le cout du live et de l'IA ;
|
||||
- definir les limites anti-abus ;
|
||||
- clarifier le telechargement prive.
|
||||
|
||||
### Lot 2 - Choix Live Ou IA
|
||||
|
||||
Objectif : rendre l'etape playback plus claire.
|
||||
|
||||
A livrer :
|
||||
|
||||
- nouvel ecran de choix ;
|
||||
- option live mise en avant ;
|
||||
- option IA accessible ;
|
||||
- avatar de conseil court ;
|
||||
- message clair sur les credits gagnes ou depenses.
|
||||
- message clair : `une seule photo pour la video IA`.
|
||||
|
||||
### Lot 3 - Playback Live Recompense
|
||||
|
||||
Objectif : encourager les utilisateurs a se filmer.
|
||||
|
||||
A livrer :
|
||||
|
||||
- enregistrement live ;
|
||||
- preview ;
|
||||
- bonus de +3 credits ;
|
||||
- incitation forte a publier ;
|
||||
- limites anti-abus.
|
||||
|
||||
### Lot 4 - Playback IA
|
||||
|
||||
Objectif : permettre de creer un playback IA a partir d'une photo.
|
||||
|
||||
A livrer :
|
||||
|
||||
- upload photo ;
|
||||
- description de scene ;
|
||||
- consentement ;
|
||||
- paiement en credits ;
|
||||
- generation video ;
|
||||
- notification quand la video est prete ;
|
||||
- preview ;
|
||||
- publication sur MusicLand.
|
||||
|
||||
### Lot 5 - Optimisation Du Flow Immersif
|
||||
|
||||
Objectif : garder l'aspect guide par avatars sans imposer de longues videos.
|
||||
|
||||
A livrer :
|
||||
|
||||
- videos avatars plus courtes ;
|
||||
- videos skippables ;
|
||||
- conseils contextuels ;
|
||||
- aide secondaire accessible ;
|
||||
- moins de transitions bloquantes.
|
||||
|
||||
### Lot 6 - Feed Et Engagement
|
||||
|
||||
Objectif : donner de la valeur a la publication.
|
||||
|
||||
A livrer :
|
||||
|
||||
- feed playback plus clair ;
|
||||
- likes et commentaires visibles ;
|
||||
- notifications d'activite ;
|
||||
- bouton pour refaire son propre playback sur une chanson ;
|
||||
- mise en avant des meilleurs playbacks.
|
||||
|
||||
## Priorites
|
||||
|
||||
Ordre conseille :
|
||||
|
||||
1. Stabiliser les credits.
|
||||
2. Ajouter le choix live / IA.
|
||||
3. Recompense live.
|
||||
4. Publication MusicLand plus incitative.
|
||||
5. Generation IA.
|
||||
6. Flow immersif avec avatars courts.
|
||||
7. Feed et engagement.
|
||||
|
||||
## Synthese
|
||||
|
||||
La bonne V1 n'est pas un clip IA complexe.
|
||||
|
||||
La bonne V1 est :
|
||||
|
||||
- un playback live recompense ;
|
||||
- un playback IA simple, reconnaissable et fiable ;
|
||||
- des avatars courts qui guident ;
|
||||
- une publication MusicLand mise en avant.
|
||||
|
||||
Ce plan garde l'application simple tout en ajoutant une vraie nouveaute produit et une meilleure incitation a publier sur MusicLand.
|
||||
|
||||
## Sources De Reference IA
|
||||
|
||||
- D-ID API : https://docs.d-id.com/reference/createtalk
|
||||
- D-ID FAQ credits et moderation : https://www.d-id.com/faqs/
|
||||
- Sync Labs API : https://sync.so/docs/api-reference/api-overview
|
||||
- Sync Labs billing : https://sync.so/docs/product/billing
|
||||
- HeyGen pricing API : https://developers.heygen.com/docs/pricing
|
||||
- OpenAI Videos API : https://platform.openai.com/docs/api-reference/videos
|
||||
@@ -0,0 +1,540 @@
|
||||
# Roadmap Playback IA MusicLand
|
||||
|
||||
## Objectif V1
|
||||
|
||||
Faire de MusicLand une plateforme ou l'utilisateur peut creer un playback, etre guide par des avatars, choisir entre un playback live ou un playback genere par IA, puis etre fortement incite a publier sa video sur MusicLand.
|
||||
|
||||
L'objectif IRL n'est pas retenu pour cette version.
|
||||
|
||||
## Principes Produit
|
||||
|
||||
### Deux chemins de creation
|
||||
|
||||
1. **Playback live**
|
||||
- L'utilisateur se filme avec la camera.
|
||||
- C'est le chemin a pousser en priorite, car il cree du contenu authentique pour la communaute.
|
||||
- L'utilisateur gagne des credits des que la video est enregistree.
|
||||
|
||||
2. **Playback IA**
|
||||
- L'utilisateur fournit une seule photo.
|
||||
- Il decrit la scene souhaitee.
|
||||
- MusicLand genere une video ou l'utilisateur est reconnaissable et fait le lip-sync sur toute la chanson.
|
||||
- Ce chemin coute des credits.
|
||||
|
||||
### Limite V1 Video IA
|
||||
|
||||
Pour cette version, la video IA fonctionne avec une seule photo et une seule personne.
|
||||
|
||||
Il faut l'indiquer clairement dans l'interface :
|
||||
|
||||
```text
|
||||
La generation IA utilise une seule photo.
|
||||
Les duos et videos avec deux personnes ne sont pas disponibles dans cette version.
|
||||
```
|
||||
|
||||
## Phase 0 - Cadrage Et Corrections Prealables
|
||||
|
||||
**Duree estimee : 2 a 4 jours**
|
||||
|
||||
### Corriger le modele de credits
|
||||
|
||||
Il existe une incoherence entre l'UI et le backend :
|
||||
|
||||
- UI : 50 / 110 / 160 credits par abonnement ;
|
||||
- backend : 10 / 40 / 60 credits.
|
||||
|
||||
Decision recommandee :
|
||||
|
||||
- aligner le backend sur l'UI ;
|
||||
- conserver 50 / 110 / 160 credits par abonnement ;
|
||||
- considerer qu'un credit vaut environ 0,10 EUR pour les arbitrages produit.
|
||||
|
||||
Raison :
|
||||
|
||||
- c'est deja ce que le client et l'utilisateur voient ;
|
||||
- les abonnements deviennent plus attractifs ;
|
||||
- le volume de credits permet d'integrer l'IA sans bloquer trop vite l'utilisateur ;
|
||||
- 10 / 40 / 60 credits est trop faible si un playback IA coute environ 60 credits.
|
||||
|
||||
Barème recommande :
|
||||
|
||||
```text
|
||||
Pack Blue : 50 credits / mois
|
||||
Pack Silver : 110 credits / mois
|
||||
Pack Gold : 160 credits / mois
|
||||
```
|
||||
|
||||
Achats ponctuels existants :
|
||||
|
||||
```text
|
||||
30 credits = 4,99 EUR
|
||||
100 credits = 9,99 EUR
|
||||
150 credits = 14,99 EUR
|
||||
```
|
||||
|
||||
Couts officiels recommandes :
|
||||
|
||||
```text
|
||||
Generation musique : 8 credits
|
||||
Playback live : +3 credits gagnes
|
||||
Playback IA : 60 credits
|
||||
Export prive : +20 credits ou reserve aux abonnes
|
||||
```
|
||||
|
||||
### Ajouter les regles anti-abus
|
||||
|
||||
Actions :
|
||||
|
||||
- limiter le bonus live a une fois par projet ;
|
||||
- ajouter une limite journaliere de 1 a 2 bonus live maximum ;
|
||||
- accorder le bonus uniquement apres fusion video reussie ;
|
||||
- ajouter des types d'operations clairs :
|
||||
- `LIVE_PLAYBACK_REWARD` ;
|
||||
- `AI_PLAYBACK_SPEND` ;
|
||||
- `AI_PLAYBACK_REFUND` ;
|
||||
- `PRIVATE_EXPORT_SPEND`.
|
||||
|
||||
Regle recommandee :
|
||||
|
||||
```text
|
||||
1 bonus live par projet
|
||||
1 a 2 bonus live maximum par jour
|
||||
bonus uniquement apres fusion video reussie
|
||||
```
|
||||
|
||||
Le bonus live doit rester motivant, mais ne doit pas devenir un moyen de farmer des credits. Le bon niveau pour la V1 est donc +3 credits, pas +5.
|
||||
|
||||
## Phase 1 - Nouveau Modele De Donnees Playback
|
||||
|
||||
**Duree estimee : 4 a 6 jours**
|
||||
|
||||
Aujourd'hui, un projet ne porte qu'un seul `playbackUrl`. Ce modele bloque :
|
||||
|
||||
- plusieurs playbacks sur une meme chanson ;
|
||||
- les brouillons ;
|
||||
- les generations IA ;
|
||||
- les statistiques propres a chaque performance.
|
||||
|
||||
### Nouvelle collection : `playbackPerformances`
|
||||
|
||||
Structure recommandee :
|
||||
|
||||
```js
|
||||
{
|
||||
projectId,
|
||||
userId,
|
||||
type: 'live' | 'ai_solo',
|
||||
status: 'draft' | 'processing' | 'ready' | 'published' | 'failed',
|
||||
videoUrl,
|
||||
sourceVideoUrl,
|
||||
aiProvider,
|
||||
aiJobId,
|
||||
photoUrl,
|
||||
consentAcceptedAt,
|
||||
prompt,
|
||||
creditsCost,
|
||||
creditsReward,
|
||||
isPublished,
|
||||
createdAt,
|
||||
publishedAt,
|
||||
likesCount,
|
||||
commentsCount,
|
||||
viewsCount
|
||||
}
|
||||
```
|
||||
|
||||
Actions :
|
||||
|
||||
- creer la collection ;
|
||||
- migrer progressivement l'usage de `project.playbackUrl` ;
|
||||
- garder `project.playbackUrl` temporairement pour compatibilite ;
|
||||
- faire lire le feed playback depuis `playbackPerformances`.
|
||||
|
||||
## Phase 2 - Nouvel Ecran De Choix Playback
|
||||
|
||||
**Duree estimee : 4 a 5 jours**
|
||||
|
||||
L'ecran actuel propose principalement l'enregistrement live. Il faut ajouter un vrai choix produit.
|
||||
|
||||
### Nouvel ecran
|
||||
|
||||
Actions :
|
||||
|
||||
- CTA principal : `Me filmer en live et gagner des credits` ;
|
||||
- CTA secondaire : `Generer mon playback avec IA` ;
|
||||
- lien : `Conseils de Theo` ;
|
||||
- video avatar courte, motivante, skippable.
|
||||
- message clair : `une seule photo pour la video IA`.
|
||||
|
||||
### Regle UX importante
|
||||
|
||||
Ne pas redemander l'emotion. L'utilisateur a deja choisi l'emotion et la musique avant d'arriver au playback.
|
||||
|
||||
Les avatars doivent conseiller sur l'interpretation :
|
||||
|
||||
- cadrage ;
|
||||
- lumiere ;
|
||||
- regard camera ;
|
||||
- energie ;
|
||||
- intention.
|
||||
|
||||
## Phase 3 - Playback Live Recompense
|
||||
|
||||
**Duree estimee : 5 a 7 jours**
|
||||
|
||||
### Parcours
|
||||
|
||||
1. L'utilisateur choisit le live.
|
||||
2. Il enregistre son playback.
|
||||
3. L'app cree une `playbackPerformance` en brouillon.
|
||||
4. La video est fusionnee avec l'audio.
|
||||
5. L'utilisateur recoit ses credits.
|
||||
6. Il arrive sur la preview.
|
||||
7. L'app pousse la publication MusicLand.
|
||||
|
||||
### Actions techniques
|
||||
|
||||
- creer une performance `live` apres enregistrement ;
|
||||
- fusionner video + musique comme aujourd'hui ;
|
||||
- accorder le bonus credit cote backend ;
|
||||
- afficher un retour immediat : `+3 credits gagnes` ;
|
||||
- empecher les recompenses multiples abusives.
|
||||
|
||||
### UX apres enregistrement
|
||||
|
||||
CTA principal :
|
||||
|
||||
```text
|
||||
Publier sur MusicLand
|
||||
```
|
||||
|
||||
CTA secondaire :
|
||||
|
||||
```text
|
||||
Recommencer
|
||||
```
|
||||
|
||||
CTA tertiaire :
|
||||
|
||||
```text
|
||||
Garder en brouillon
|
||||
```
|
||||
|
||||
## Phase 4 - Playback IA
|
||||
|
||||
**Duree estimee : 8 a 12 jours**
|
||||
|
||||
### Provider recommande V1
|
||||
|
||||
Provider recommande : **D-ID**.
|
||||
|
||||
Raison :
|
||||
|
||||
- adapte a une photo + audio ;
|
||||
- compatible avec une chanson complete jusqu'a 5 minutes ;
|
||||
- plus simple pour une V1 ;
|
||||
- cout plus realiste que les solutions video premium.
|
||||
|
||||
Limite assumee :
|
||||
|
||||
- rendu prioritairement portrait ou buste ;
|
||||
- une seule photo ;
|
||||
- une seule personne ;
|
||||
- pas de duo ;
|
||||
- pas de corps entier ou danse complexe en V1.
|
||||
|
||||
### Parcours IA
|
||||
|
||||
1. L'utilisateur choisit `Generer avec IA`.
|
||||
2. Il upload une seule photo.
|
||||
3. Il accepte les regles :
|
||||
- utilisateur majeur ;
|
||||
- consentement obligatoire ;
|
||||
- interdiction d'utiliser une celebrite ;
|
||||
- interdiction d'utiliser un tiers sans autorisation.
|
||||
4. Il decrit la scene.
|
||||
5. L'app affiche le cout en credits.
|
||||
6. Le backend debite les credits.
|
||||
7. Le backend lance la generation IA.
|
||||
8. L'utilisateur voit un statut `generation en cours`.
|
||||
9. L'app notifie quand la video est prete.
|
||||
10. L'utilisateur previsualise.
|
||||
11. L'app pousse la publication MusicLand.
|
||||
|
||||
### Mouvement V1 recommande
|
||||
|
||||
Pour maximiser la faisabilite :
|
||||
|
||||
- portrait ou buste ;
|
||||
- face camera ;
|
||||
- mouvement leger ;
|
||||
- decor simple ;
|
||||
- pas de danse complexe ;
|
||||
- pas de full-body ;
|
||||
- pas de camera dynamique.
|
||||
|
||||
### Prompt utilisateur
|
||||
|
||||
Champ simple :
|
||||
|
||||
```text
|
||||
Decris la scene de ton playback
|
||||
```
|
||||
|
||||
Exemples :
|
||||
|
||||
- `sur scene avec des lumieres rouges` ;
|
||||
- `dans une chambre intime, ambiance triste` ;
|
||||
- `clip pop lumineux, fond violet`.
|
||||
|
||||
## Phase 5 - Limites Video IA V1
|
||||
|
||||
**Statut : hors scope V1**
|
||||
|
||||
Le duo n'est pas prevu pour cette version. Le flow IA doit rester limite a une seule photo et une seule personne.
|
||||
|
||||
Il ne faut donc pas ouvrir de parcours permettant d'ajouter une deuxieme image ou de creer une video avec deux personnes.
|
||||
|
||||
Message produit a afficher cote IA :
|
||||
|
||||
```text
|
||||
Pour cette version, la video IA fonctionne avec une seule photo.
|
||||
```
|
||||
|
||||
## Phase 6 - Solutions IA A Tester
|
||||
|
||||
**Duree estimee : 3 a 5 jours de benchmark**
|
||||
|
||||
Le besoin MusicLand est specifique : photo unique, chanson complete, utilisateur reconnaissable, lip-sync precis.
|
||||
|
||||
### Option 1 - D-ID
|
||||
|
||||
Statut : recommandation principale pour le MVP.
|
||||
|
||||
Raison :
|
||||
|
||||
- endpoint adapte a une image source et un audio ;
|
||||
- logique de talking head compatible avec le playback ;
|
||||
- moderation image/audio deja prevue ;
|
||||
- cout et integration plus accessibles pour demarrer.
|
||||
|
||||
### Option 2 - Sync Labs
|
||||
|
||||
Statut : challenger a tester.
|
||||
|
||||
Raison :
|
||||
|
||||
- specialiste du lip-sync ;
|
||||
- accepte image ou video + audio ;
|
||||
- interessant si la precision bouche/audio devient le critere principal.
|
||||
|
||||
Limite :
|
||||
|
||||
- cout plus eleve ;
|
||||
- benchmark obligatoire sur plusieurs chansons completes.
|
||||
|
||||
### Option 3 - HeyGen
|
||||
|
||||
Statut : option premium.
|
||||
|
||||
Raison :
|
||||
|
||||
- rendu avatar propre ;
|
||||
- ecosysteme mature.
|
||||
|
||||
Limite :
|
||||
|
||||
- cout plus eleve pour une chanson complete ;
|
||||
- moins adapte comme premier choix MVP grand public.
|
||||
|
||||
### Non prioritaire - Generateurs video generalistes
|
||||
|
||||
Sora et les autres generateurs prompt-to-video ne sont pas le bon choix principal pour cette V1.
|
||||
|
||||
Raison :
|
||||
|
||||
- duree des clips trop courte pour une chanson complete ;
|
||||
- lip-sync chanson complete moins fiable ;
|
||||
- identite utilisateur moins controllable ;
|
||||
- cout plus difficile a maitriser.
|
||||
|
||||
## Phase 7 - Avatars Guides Et Flow Court
|
||||
|
||||
**Duree estimee : 4 a 6 jours, en parallele**
|
||||
|
||||
Le client veut conserver l'aspect guide par avatars, mais sans longues videos de transition.
|
||||
|
||||
La logique recommandee est de remplacer les videos longues par des interventions courtes, contextuelles et skippables.
|
||||
|
||||
### Videos avatars a prevoir
|
||||
|
||||
- avant playback : motivation et intention ;
|
||||
- avant live : cadrage, lumiere, regard camera ;
|
||||
- avant IA : photo claire, visage face camera, scene simple ;
|
||||
- apres creation : incitation a publier ;
|
||||
- apres publication : felicitation et partage.
|
||||
|
||||
### Regles UX
|
||||
|
||||
- videos preregistrees identiques pour tous ;
|
||||
- duree cible : 5 a 10 secondes ;
|
||||
- skippables ;
|
||||
- sous-titres ;
|
||||
- ne pas les relancer a chaque fois ;
|
||||
- memoriser les videos vues dans `seenAvatarVideos`.
|
||||
|
||||
Objectif : guider sans ralentir le parcours.
|
||||
|
||||
### Flow cible
|
||||
|
||||
```text
|
||||
Choix Playback
|
||||
-> Live ou IA
|
||||
-> Conseil avatar court
|
||||
-> Creation
|
||||
-> Preview
|
||||
-> Publier sur MusicLand
|
||||
```
|
||||
|
||||
## Phase 8 - Incitation A Publier Sur MusicLand
|
||||
|
||||
**Duree estimee : 5 a 8 jours**
|
||||
|
||||
Le produit doit rendre la publication plus desirable que le telechargement direct.
|
||||
|
||||
### Regle produit
|
||||
|
||||
Publication MusicLand = chemin principal.
|
||||
|
||||
Telechargement prive = possible, mais moins avantageux.
|
||||
|
||||
### Ecran de fin recommande
|
||||
|
||||
CTA principal :
|
||||
|
||||
```text
|
||||
Publier sur MusicLand et recuperer ma video HD
|
||||
```
|
||||
|
||||
CTA secondaire :
|
||||
|
||||
```text
|
||||
Garder en brouillon
|
||||
```
|
||||
|
||||
CTA tertiaire discret :
|
||||
|
||||
```text
|
||||
Telecharger sans publier
|
||||
```
|
||||
|
||||
### Incitations
|
||||
|
||||
- publication = video HD incluse ;
|
||||
- publication = visibilite dans le feed ;
|
||||
- publication = likes et commentaires ;
|
||||
- publication = badge ou petit bonus credit ;
|
||||
- non publie = brouillon prive ;
|
||||
- telechargement sans publication = payant ou reserve aux abonnes.
|
||||
|
||||
## Phase 9 - Feed Playback Et Validation Sociale
|
||||
|
||||
**Duree estimee : 8 a 12 jours**
|
||||
|
||||
Le feed doit devenir un vrai moteur communautaire.
|
||||
|
||||
### Actions
|
||||
|
||||
- lire les performances publiees depuis `playbackPerformances` ;
|
||||
- ajouter tri :
|
||||
- recent ;
|
||||
- populaire ;
|
||||
- abonnements ;
|
||||
- compter les vues playback reellement ;
|
||||
- liker/commenter une performance, pas seulement un projet ;
|
||||
- ajouter un vrai ecran activite ;
|
||||
- ajouter `Faire mon playback sur cette chanson` ;
|
||||
- ajouter `Repondre en playback`.
|
||||
|
||||
Objectif : chaque playback publie doit donner envie a quelqu'un d'autre d'en faire un.
|
||||
|
||||
## Phase 10 - Moderation, Consentement Et Securite
|
||||
|
||||
**Duree estimee : 5 a 8 jours**
|
||||
|
||||
Obligatoire avant de publier des videos IA.
|
||||
|
||||
### Actions
|
||||
|
||||
- consentement photo obligatoire ;
|
||||
- stockage `consentAcceptedAt` ;
|
||||
- confirmation utilisateur majeur ;
|
||||
- interdiction celebrites ;
|
||||
- interdiction tiers sans autorisation ;
|
||||
- signalement playback IA ;
|
||||
- suppression video ;
|
||||
- logs provider ;
|
||||
- remboursement automatique si generation echoue ;
|
||||
- moderation photo/audio via provider et regles internes.
|
||||
|
||||
## Phase 11 - Analytics Produit
|
||||
|
||||
**Duree estimee : 3 a 5 jours**
|
||||
|
||||
### Evenements a tracker
|
||||
|
||||
```text
|
||||
playback_live_started
|
||||
playback_live_recorded
|
||||
playback_live_published
|
||||
ai_playback_started
|
||||
ai_photo_uploaded
|
||||
ai_generation_paid
|
||||
ai_generation_ready
|
||||
ai_playback_published
|
||||
download_without_publish
|
||||
publish_after_preview
|
||||
feed_playback_view
|
||||
playback_like
|
||||
playback_comment
|
||||
```
|
||||
|
||||
### KPIs V1
|
||||
|
||||
- taux de publication apres creation ;
|
||||
- cout moyen par generation IA ;
|
||||
- taux d'echec IA ;
|
||||
- part des videos telechargees sans publication ;
|
||||
- nombre de playbacks publies par chanson ;
|
||||
- delai moyen de generation IA ;
|
||||
- ratio live vs IA ;
|
||||
- taux de transformation preview vers publication.
|
||||
|
||||
## Priorite De Developpement
|
||||
|
||||
Ordre conseille :
|
||||
|
||||
1. Corriger les credits.
|
||||
2. Creer `playbackPerformances`.
|
||||
3. Refaire l'ecran choix live / IA.
|
||||
4. Ajouter la recompense live.
|
||||
5. Ajouter la publication incitative.
|
||||
6. Integrer l'IA D-ID.
|
||||
7. Ajouter les notifications de generation.
|
||||
8. Optimiser les avatars courts et les flows sans videos longues.
|
||||
9. Refaire le feed playback.
|
||||
10. Ajouter analytics et moderation complete.
|
||||
|
||||
## Synthese
|
||||
|
||||
La V1 ne doit pas chercher a produire un clip IA parfait avec corps entier, danse et mise en scene complexe.
|
||||
|
||||
Le meilleur MVP est :
|
||||
|
||||
- playback live recompense ;
|
||||
- playback IA depuis photo avec lip-sync sur chanson complete ;
|
||||
- avatars courts pour guider ;
|
||||
- publication MusicLand comme chemin principal ;
|
||||
- telechargement possible mais moins incitatif.
|
||||
|
||||
Ce cadrage donne le meilleur equilibre entre faisabilite technique, cout IA, monetisation et creation de contenu sur MusicLand.
|
||||
Generated
+5761
-109
File diff suppressed because it is too large
Load Diff
@@ -34,7 +34,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "^8.15.0",
|
||||
"eslint-config-google": "^0.14.0"
|
||||
"eslint-config-google": "^0.14.0",
|
||||
"firebase-tools": "^15.28.1"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ const path = require('path')
|
||||
const { ALERT_TYPE, refList } = require('../index')
|
||||
const { sendNotification } = require('./notifications')
|
||||
|
||||
const bucket = admin.storage().bucket()
|
||||
const COVER_TEMPLATE = 'MUSICLAND_PLAYBACKER'
|
||||
const COVER_OPTION_ID = 'musicland-playbacker'
|
||||
const COVER_SIZE = 1024
|
||||
@@ -147,6 +146,7 @@ async function buildProfileOverlay(profilePictureURL) {
|
||||
}
|
||||
|
||||
async function uploadCover(buffer, targetPath) {
|
||||
const bucket = admin.storage().bucket()
|
||||
const token = crypto.randomUUID()
|
||||
const file = bucket.file(targetPath)
|
||||
|
||||
|
||||
+10
-3
@@ -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 { refList } = require('../index')
|
||||
const { REGION, refList } = require('../index')
|
||||
|
||||
// --- CONSTANTES DE STRUCTURE ---
|
||||
// On garde ces mappings car ils sont utiles pour normaliser l'input utilisateur
|
||||
@@ -59,6 +59,13 @@ const STRUCTURE_ALIASES = {
|
||||
soft_transition: 'transition_douce',
|
||||
}
|
||||
|
||||
const LYRICS_FUNCTION_OPTIONS = {
|
||||
region: REGION,
|
||||
cors: true,
|
||||
invoker: 'public',
|
||||
secrets: [GEMINI_API_KEY],
|
||||
}
|
||||
|
||||
// --- UTILITAIRES ---
|
||||
|
||||
const normalizeStructureValue = (value) => {
|
||||
@@ -137,7 +144,7 @@ const buildModerationBrief = ({
|
||||
|
||||
// --- GENERATION DE PAROLES (MAIN) ---
|
||||
|
||||
exports.generateLyrics = onCall({ secrets: [GEMINI_API_KEY] }, async ({ auth = {}, data = {} }) => {
|
||||
exports.generateLyrics = onCall(LYRICS_FUNCTION_OPTIONS, async ({ auth = {}, data = {} }) => {
|
||||
try {
|
||||
const {
|
||||
lang: rawLang,
|
||||
@@ -357,7 +364,7 @@ const softenModerationDecision = (rawResult = {}) => {
|
||||
}
|
||||
}
|
||||
|
||||
exports.analyseLyricsToxicity = onCall({ secrets: [GEMINI_API_KEY] }, async ({ data = {} }) => {
|
||||
exports.analyseLyricsToxicity = onCall(LYRICS_FUNCTION_OPTIONS, async ({ data = {} }) => {
|
||||
try {
|
||||
const requestSchema = z.object({
|
||||
title: z.string().optional(),
|
||||
|
||||
@@ -9,7 +9,12 @@ const {
|
||||
COIN_PACK_PRODUCTS,
|
||||
} = require('./constants')
|
||||
const { parseCoinsPerMonth, formatCoinPack } = require('./shared')
|
||||
const STRIPE_FUNCTION_OPTIONS = { region: REGION, secrets: [STRIPE_SECRET_KEY] }
|
||||
const STRIPE_FUNCTION_OPTIONS = {
|
||||
region: REGION,
|
||||
cors: true,
|
||||
invoker: 'public',
|
||||
secrets: [STRIPE_SECRET_KEY],
|
||||
}
|
||||
|
||||
const formatPlan = (price, priceId) => {
|
||||
if (!price || typeof price !== 'object') {
|
||||
|
||||
@@ -18,7 +18,12 @@ const {
|
||||
const { refsList, formatCoinPack, getSubscriptionMetaFromPrice } = require('./shared')
|
||||
|
||||
const CHECKOUT_UI_MODES = new Set(['hosted', 'embedded'])
|
||||
const STRIPE_FUNCTION_OPTIONS = { region: REGION, secrets: [STRIPE_SECRET_KEY] }
|
||||
const STRIPE_FUNCTION_OPTIONS = {
|
||||
region: REGION,
|
||||
cors: true,
|
||||
invoker: 'public',
|
||||
secrets: [STRIPE_SECRET_KEY],
|
||||
}
|
||||
|
||||
const sanitizePriceId = (value) => {
|
||||
if (typeof value !== 'string') {
|
||||
|
||||
@@ -5,7 +5,12 @@ const { STRIPE_SECRET_KEY } = require('../../config/secrets')
|
||||
const { REGION } = require('./config')
|
||||
const { CANCELABLE_SUBSCRIPTION_STATUSES, ACTIVE_SUBSCRIPTION_STATUSES } = require('./constants')
|
||||
const { refsList, formatSubscriptionForClient, resolveUserContext } = require('./shared')
|
||||
const STRIPE_FUNCTION_OPTIONS = { region: REGION, secrets: [STRIPE_SECRET_KEY] }
|
||||
const STRIPE_FUNCTION_OPTIONS = {
|
||||
region: REGION,
|
||||
cors: true,
|
||||
invoker: 'public',
|
||||
secrets: [STRIPE_SECRET_KEY],
|
||||
}
|
||||
|
||||
const cancelActiveSubscription = onCall(STRIPE_FUNCTION_OPTIONS, async (request) => {
|
||||
try {
|
||||
|
||||
@@ -250,6 +250,9 @@ export const videos = {
|
||||
test:
|
||||
Platform.OS === 'web' ? require('./video/testVideoWeb.mp4') : require('./video/testVideo.mp4'),
|
||||
club: require('./video/club.mp4'),
|
||||
entryJingle: require('./video/entry-jingle.mp4'),
|
||||
welcomeFr: require('./video/welcome-fr.mp4'),
|
||||
welcomeEn: require('./video/welcome-en.mp4'),
|
||||
}
|
||||
|
||||
export const img = {
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
+15
-8
@@ -7,7 +7,7 @@ import { Fonts, Palette, gutters } from '../styles'
|
||||
import GradientButton from './GradientButton'
|
||||
import { LinearGradient } from './LinearGradient/LinearGradient'
|
||||
import Overlay from './Overlay'
|
||||
const WebAlertModal = ({ title, description, options }) => {
|
||||
const WebAlertModal = ({ title, description, options, onDismiss }) => {
|
||||
const [visible, setVisible] = useState(true)
|
||||
|
||||
const confirmOption = options?.find(({ style }) => style !== 'cancel')
|
||||
@@ -15,14 +15,18 @@ const WebAlertModal = ({ title, description, options }) => {
|
||||
const hasSecondaryAction = Boolean(cancelOption)
|
||||
const buttonContainerStyle = hasSecondaryAction ? styles.actionButton : styles.singleActionButton
|
||||
|
||||
const onConfirm = () => {
|
||||
const dismiss = (callback) => {
|
||||
setVisible(false)
|
||||
confirmOption?.onPress()
|
||||
setTimeout(onDismiss, 500)
|
||||
callback?.()
|
||||
}
|
||||
|
||||
const onConfirm = () => {
|
||||
dismiss(confirmOption?.onPress)
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
setVisible(false)
|
||||
cancelOption?.onPress()
|
||||
dismiss(cancelOption?.onPress)
|
||||
}
|
||||
|
||||
const renderDescription = () => {
|
||||
@@ -80,8 +84,12 @@ const alertPolyfill = (title, description, options, extra) => {
|
||||
const rootDiv = document.createElement('div')
|
||||
document.body.appendChild(rootDiv)
|
||||
|
||||
const { createRoot } = require('react-dom/client')
|
||||
const root = createRoot(rootDiv)
|
||||
|
||||
const closeModal = () => {
|
||||
document.body.removeChild(rootDiv)
|
||||
root.unmount()
|
||||
rootDiv.remove()
|
||||
}
|
||||
|
||||
const WebAlertComponent = () => (
|
||||
@@ -93,8 +101,7 @@ const alertPolyfill = (title, description, options, extra) => {
|
||||
/>
|
||||
)
|
||||
|
||||
// Render the React component into the div
|
||||
require('react-dom').render(<WebAlertComponent />, rootDiv)
|
||||
root.render(<WebAlertComponent />)
|
||||
}
|
||||
|
||||
const customAlert = (title, description, options, extra) => {
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
import { MaterialIcons } from '@expo/vector-icons'
|
||||
import { Asset } from 'expo-asset'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native'
|
||||
|
||||
const resolveModuleUri = async (source) => {
|
||||
const asset = Asset.fromModule(source)
|
||||
|
||||
if (!asset.localUri && !asset.uri) {
|
||||
await asset.downloadAsync()
|
||||
}
|
||||
|
||||
return asset.localUri ?? asset.uri ?? null
|
||||
}
|
||||
import { resolveMediaUri } from '../utils/resolveMediaUri'
|
||||
|
||||
const BackgroundVideo = ({ source, soundButtonStyle = null }) => {
|
||||
const videoRef = useRef(null)
|
||||
@@ -28,16 +19,9 @@ const BackgroundVideo = ({ source, soundButtonStyle = null }) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof source === 'string') {
|
||||
setUri(source)
|
||||
return () => {
|
||||
isMounted = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadSource = async () => {
|
||||
try {
|
||||
const nextUri = await resolveModuleUri(source)
|
||||
const nextUri = await resolveMediaUri(source)
|
||||
if (isMounted) {
|
||||
setUri(nextUri)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,13 @@ import { Portal } from '@gorhom/portal'
|
||||
// - onClose: () => void, called when user skips or when video ends
|
||||
const CLOSE_THRESHOLD_SECONDS = 0.35
|
||||
|
||||
const FullscreenIntroVideo = ({ url, visible = true, onClose }) => {
|
||||
const FullscreenIntroVideo = ({
|
||||
url,
|
||||
visible = true,
|
||||
onClose,
|
||||
contentFit = 'contain',
|
||||
skipLabel = 'Passer la vidéo',
|
||||
}) => {
|
||||
const source = url ? (typeof url === 'string' ? { uri: url } : url) : videos.test
|
||||
|
||||
const hasClosedRef = useRef(false)
|
||||
@@ -103,7 +109,7 @@ const FullscreenIntroVideo = ({ url, visible = true, onClose }) => {
|
||||
<VideoView
|
||||
player={player}
|
||||
nativeControls={false}
|
||||
contentFit="contain"
|
||||
contentFit={contentFit}
|
||||
height="100%"
|
||||
width="100%"
|
||||
// style={{
|
||||
@@ -128,7 +134,7 @@ const FullscreenIntroVideo = ({ url, visible = true, onClose }) => {
|
||||
borderColor: '#FFFFFF55',
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: '#FFF', fontSize: 14 }}>Passer la vidéo</Text>
|
||||
<Text style={{ color: '#FFF', fontSize: 14 }}>{skipLabel}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</Portal>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Portal } from '@gorhom/portal'
|
||||
import { Asset } from 'expo-asset'
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Pressable, Text, View } from 'react-native'
|
||||
|
||||
import { resolveMediaUri } from '../utils/resolveMediaUri'
|
||||
|
||||
const CLOSE_THRESHOLD_SECONDS = 0.35
|
||||
const CLOSE_POLL_INTERVAL_MS = 500
|
||||
|
||||
@@ -45,20 +46,30 @@ const closeTextStyle = {
|
||||
fontSize: 14,
|
||||
}
|
||||
|
||||
const resolveModuleUri = async (module) => {
|
||||
const asset = Asset.fromModule(module)
|
||||
|
||||
if (!asset.localUri && !asset.uri) {
|
||||
await asset.downloadAsync()
|
||||
}
|
||||
|
||||
return asset.localUri ?? asset.uri ?? null
|
||||
const playButtonStyle = {
|
||||
backgroundColor: '#000000CC',
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 20,
|
||||
borderRadius: 24,
|
||||
borderWidth: 1,
|
||||
borderColor: '#FFFFFF88',
|
||||
cursor: 'pointer',
|
||||
zIndex: 1,
|
||||
}
|
||||
|
||||
const FullscreenIntroVideo = ({ url, visible = true, onClose }) => {
|
||||
const FullscreenIntroVideo = ({
|
||||
url,
|
||||
visible = true,
|
||||
onClose,
|
||||
contentFit = 'cover',
|
||||
requireAudio = false,
|
||||
skipLabel = 'Passer la vidéo',
|
||||
playLabel = 'Lire la vidéo avec le son',
|
||||
}) => {
|
||||
const videoRef = useRef(null)
|
||||
const [uri, setUri] = useState(null)
|
||||
const [muted, setMuted] = useState(false)
|
||||
const [awaitingInteraction, setAwaitingInteraction] = useState(false)
|
||||
const hasClosedRef = useRef(false)
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
@@ -92,20 +103,14 @@ const FullscreenIntroVideo = ({ url, visible = true, onClose }) => {
|
||||
const assignUri = (nextUri) => {
|
||||
if (isMounted) {
|
||||
setMuted(false)
|
||||
setAwaitingInteraction(false)
|
||||
setUri(nextUri)
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof url === 'string') {
|
||||
assignUri(url)
|
||||
return () => {
|
||||
isMounted = false
|
||||
}
|
||||
}
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const nextUri = await resolveModuleUri(url)
|
||||
const nextUri = await resolveMediaUri(url)
|
||||
assignUri(nextUri)
|
||||
} catch {
|
||||
if (isMounted) {
|
||||
@@ -142,7 +147,9 @@ const FullscreenIntroVideo = ({ url, visible = true, onClose }) => {
|
||||
|
||||
if (result?.catch) {
|
||||
result.catch((error) => {
|
||||
if (error?.name === 'NotAllowedError' && !muted) {
|
||||
if (error?.name === 'NotAllowedError' && requireAudio) {
|
||||
setAwaitingInteraction(true)
|
||||
} else if (error?.name === 'NotAllowedError' && !muted) {
|
||||
setMuted(true)
|
||||
}
|
||||
})
|
||||
@@ -160,7 +167,20 @@ const FullscreenIntroVideo = ({ url, visible = true, onClose }) => {
|
||||
const video = videoRef.current
|
||||
video?.pause()
|
||||
}
|
||||
}, [evaluateShouldClose, muted, uri, visible])
|
||||
}, [evaluateShouldClose, muted, requireAudio, uri, visible])
|
||||
|
||||
const handleManualPlay = useCallback(() => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
|
||||
setMuted(false)
|
||||
video.muted = false
|
||||
video.currentTime = 0
|
||||
const result = video.play()
|
||||
if (result?.then) {
|
||||
result.then(() => setAwaitingInteraction(false)).catch(() => {})
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current
|
||||
@@ -187,7 +207,7 @@ const FullscreenIntroVideo = ({ url, visible = true, onClose }) => {
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={uri}
|
||||
style={videoStyle}
|
||||
style={{ ...videoStyle, objectFit: contentFit }}
|
||||
playsInline
|
||||
autoPlay
|
||||
loop={false}
|
||||
@@ -199,8 +219,13 @@ const FullscreenIntroVideo = ({ url, visible = true, onClose }) => {
|
||||
onError={handleClose}
|
||||
/>
|
||||
) : null}
|
||||
{awaitingInteraction ? (
|
||||
<Pressable onPress={handleManualPlay} style={playButtonStyle}>
|
||||
<Text style={closeTextStyle}>{playLabel}</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
<Pressable onPress={handleClose} style={closeButtonStyle}>
|
||||
<Text style={closeTextStyle}>Passer la vidéo</Text>
|
||||
<Text style={closeTextStyle}>{skipLabel}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</Portal>
|
||||
|
||||
@@ -22,6 +22,8 @@ export default ({
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: gutters / 2,
|
||||
paddingTop: 10,
|
||||
position: 'relative',
|
||||
zIndex: 10,
|
||||
...containerStyle,
|
||||
},
|
||||
]}
|
||||
@@ -29,7 +31,23 @@ export default ({
|
||||
<View style={{ flex: 1 }}>
|
||||
{!hideBackButton && (
|
||||
<Pressable
|
||||
onPress={() => onBackPressed?.() || goBack()}
|
||||
onPress={() => {
|
||||
if (onBackPressed) {
|
||||
onBackPressed()
|
||||
return
|
||||
}
|
||||
goBack()
|
||||
}}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Retour"
|
||||
style={{
|
||||
width: 44,
|
||||
height: 44,
|
||||
alignSelf: 'flex-start',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1,
|
||||
}}
|
||||
hitSlop={{ top: 16, right: 16, bottom: 16, left: 16 }}
|
||||
>
|
||||
<Image
|
||||
|
||||
+12
-7
@@ -10,7 +10,7 @@ import { Platform } from 'react-native'
|
||||
const functionsInstances = {}
|
||||
const emulatorConfigured = {}
|
||||
const emulatorHost = Platform.OS === 'web' ? 'localhost' : '192.168.1.60'
|
||||
const USE_FUNCTIONS_EMULATOR = false // Toggle to route functions traffic to the local emulator.
|
||||
const USE_FUNCTIONS_EMULATOR = __DEV__
|
||||
|
||||
const configureFunctionsEmulator = (instance, regionKey = 'us-central1') => {
|
||||
const shouldUseEmulator = USE_FUNCTIONS_EMULATOR && instance?.useEmulator
|
||||
@@ -56,15 +56,20 @@ if (!firebase?.apps?.filter(({ name_ }) => name_ === '[DEFAULT]').length) {
|
||||
console.log('Firebase init')
|
||||
}
|
||||
|
||||
// Firestore settings for React Native/iOS: avoid streaming transport issues
|
||||
const firestore = firebase.firestore()
|
||||
try {
|
||||
// Prefer auto-detect; disable fetch streams for RN iOS stability
|
||||
firebase.firestore().settings({
|
||||
experimentalAutoDetectLongPolling: true,
|
||||
useFetchStreams: false,
|
||||
const firestoreSettings = {
|
||||
ignoreUndefinedProperties: true,
|
||||
})
|
||||
merge: true,
|
||||
}
|
||||
|
||||
// React Native can require long polling, but these settings break web streaming.
|
||||
if (Platform.OS !== 'web') {
|
||||
firestoreSettings.experimentalAutoDetectLongPolling = true
|
||||
firestoreSettings.useFetchStreams = false
|
||||
}
|
||||
|
||||
firestore.settings(firestoreSettings)
|
||||
} catch (e) {
|
||||
// Ignore if settings were already set elsewhere
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { resolveMediaUri } from '../utils/resolveMediaUri'
|
||||
|
||||
const useResolvedMediaUri = (source) => {
|
||||
const [uri, setUri] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true
|
||||
|
||||
const loadUri = async () => {
|
||||
try {
|
||||
const resolvedUri = await resolveMediaUri(source)
|
||||
if (isMounted) {
|
||||
setUri(resolvedUri)
|
||||
}
|
||||
} catch {
|
||||
if (isMounted) {
|
||||
setUri(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadUri()
|
||||
|
||||
return () => {
|
||||
isMounted = false
|
||||
}
|
||||
}, [source])
|
||||
|
||||
return uri
|
||||
}
|
||||
|
||||
export default useResolvedMediaUri
|
||||
@@ -5,6 +5,7 @@ import { img } from '../../../assets'
|
||||
import { Palette, Style } from '../../../styles'
|
||||
import { FONT_FAMILY } from '../../../styles/Fonts'
|
||||
import SubscriptionBadge from '../../../components/SubscriptionBadge'
|
||||
import useResolvedMediaUri from '../../../hooks/useResolvedMediaUri'
|
||||
|
||||
const PlaybacksCard = ({
|
||||
rank = 1,
|
||||
@@ -18,6 +19,7 @@ const PlaybacksCard = ({
|
||||
const resolveUri = (value) =>
|
||||
typeof value === 'string' && value.trim().length > 0 ? value : null
|
||||
const imageUri = resolveUri(thumbnailUrl) ?? resolveUri(coverUrl)
|
||||
const resolvedImageUri = useResolvedMediaUri(imageUri)
|
||||
|
||||
return (
|
||||
<Pressable onPress={onPress}>
|
||||
@@ -63,7 +65,7 @@ const PlaybacksCard = ({
|
||||
{rank}
|
||||
</Text>
|
||||
<Image
|
||||
source={imageUri ? { uri: imageUri } : img.placeholder3}
|
||||
source={resolvedImageUri ? { uri: resolvedImageUri } : img.placeholder3}
|
||||
style={{ width: 67, height: 108, borderRadius: 16 }}
|
||||
/>
|
||||
<View style={{ flex: 1 }}>
|
||||
|
||||
@@ -7,6 +7,7 @@ import Style, { size } from '../../../styles/Style'
|
||||
import { Palette } from '../../../styles'
|
||||
import { FONT_FAMILY } from '../../../styles/Fonts'
|
||||
import SubscriptionBadge from '../../../components/SubscriptionBadge'
|
||||
import useResolvedMediaUri from '../../../hooks/useResolvedMediaUri'
|
||||
|
||||
const SongCard = ({
|
||||
rank = 1,
|
||||
@@ -17,6 +18,8 @@ const SongCard = ({
|
||||
subscriptionLevel = null,
|
||||
views = null,
|
||||
}) => {
|
||||
const resolvedCoverUrl = useResolvedMediaUri(coverUrl)
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress || undefined}
|
||||
@@ -25,9 +28,9 @@ const SongCard = ({
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
{coverUrl ? (
|
||||
{resolvedCoverUrl ? (
|
||||
<ExpoImage
|
||||
source={{ uri: coverUrl }}
|
||||
source={{ uri: resolvedCoverUrl }}
|
||||
cachePolicy="memory-disk"
|
||||
priority="high"
|
||||
contentFit="cover"
|
||||
|
||||
+66
-67
@@ -1,19 +1,8 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Image,
|
||||
Platform,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native'
|
||||
import { background, icons } from '../assets'
|
||||
import EntryJingle from '../components/EntryJingle'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { Image, StyleSheet, Text, TouchableOpacity, View } from 'react-native'
|
||||
import { background, icons, videos } from '../assets'
|
||||
import FullscreenIntroVideo from '../components/FullscreenIntroVideo'
|
||||
import { videosRef } from '../config/firebase'
|
||||
import useDataFromRef from '../hooks/useDataFromRef'
|
||||
import Page from '../layouts/Page'
|
||||
import { Routes } from '../navigation'
|
||||
import { navigate, reset } from '../navigation/NavigationService'
|
||||
@@ -22,45 +11,25 @@ import { Palette } from '../styles'
|
||||
import { FONT_FAMILY } from '../styles/Fonts'
|
||||
|
||||
const LANGUAGE_STORAGE_KEY = 'preferredLanguage'
|
||||
const FLOW_PHASE = {
|
||||
JINGLE: 'jingle',
|
||||
LANGUAGE: 'language',
|
||||
WELCOME_VIDEO: 'welcomeVideo',
|
||||
}
|
||||
|
||||
export default function LandingPage() {
|
||||
export default function LandingPage({ route }) {
|
||||
const { currentUID } = useUser()
|
||||
const [videoUrl, setVideoUrl] = useState(null)
|
||||
const [phase, setPhase] = useState(() =>
|
||||
route?.params?.startAtLanguage ? FLOW_PHASE.LANGUAGE : FLOW_PHASE.JINGLE
|
||||
)
|
||||
const [selectedLanguage, setSelectedLanguage] = useState(null)
|
||||
const continuationHandledRef = useRef(false)
|
||||
const isAuthenticated = !!currentUID
|
||||
|
||||
const { data: welcomeVideo, loading: welcomeVideoLoading } = useDataFromRef({
|
||||
ref: selectedLanguage ? videosRef.doc(selectedLanguage) : null,
|
||||
initialState: null,
|
||||
simpleRef: true,
|
||||
condition: !!selectedLanguage,
|
||||
refreshArray: [selectedLanguage],
|
||||
})
|
||||
|
||||
const openRegistration = useCallback(() => {
|
||||
navigate(Routes.Register)
|
||||
}, [])
|
||||
|
||||
const handleSelectLanguage = useCallback(async (language) => {
|
||||
try {
|
||||
await AsyncStorage.setItem(LANGUAGE_STORAGE_KEY, language)
|
||||
} catch (error) {
|
||||
console.warn('LandingPage: failed to persist language', error)
|
||||
}
|
||||
continuationHandledRef.current = false
|
||||
setSelectedLanguage(language)
|
||||
}, [])
|
||||
|
||||
const handleVideoClose = useCallback(() => {
|
||||
setVideoUrl(null)
|
||||
openRegistration()
|
||||
}, [openRegistration])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
return
|
||||
}
|
||||
const openMainMenu = useCallback(() => {
|
||||
reset({
|
||||
index: 0,
|
||||
routes: [
|
||||
@@ -73,33 +42,55 @@ export default function LandingPage() {
|
||||
},
|
||||
],
|
||||
})
|
||||
}, [isAuthenticated])
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!selectedLanguage ||
|
||||
welcomeVideoLoading ||
|
||||
continuationHandledRef.current ||
|
||||
isAuthenticated
|
||||
) {
|
||||
const handleJingleClose = useCallback(() => {
|
||||
if (isAuthenticated) {
|
||||
openMainMenu()
|
||||
return
|
||||
}
|
||||
setPhase(FLOW_PHASE.LANGUAGE)
|
||||
}, [isAuthenticated, openMainMenu])
|
||||
|
||||
continuationHandledRef.current = true
|
||||
const nextVideoUrl =
|
||||
Platform.OS === 'web' ? welcomeVideo?.registrationIntroWeb : welcomeVideo?.registrationIntro
|
||||
|
||||
if (typeof nextVideoUrl === 'string' && nextVideoUrl.trim().length > 0) {
|
||||
setVideoUrl(nextVideoUrl)
|
||||
return
|
||||
const handleSelectLanguage = useCallback(async (language) => {
|
||||
try {
|
||||
await AsyncStorage.setItem(LANGUAGE_STORAGE_KEY, language)
|
||||
} catch (error) {
|
||||
console.warn('LandingPage: failed to persist language', error)
|
||||
}
|
||||
setSelectedLanguage(language)
|
||||
setPhase(FLOW_PHASE.WELCOME_VIDEO)
|
||||
}, [])
|
||||
|
||||
openRegistration()
|
||||
}, [isAuthenticated, openRegistration, selectedLanguage, welcomeVideo, welcomeVideoLoading])
|
||||
const handleWelcomeVideoClose = useCallback(() => {
|
||||
setSelectedLanguage(null)
|
||||
setPhase(FLOW_PHASE.LANGUAGE)
|
||||
requestAnimationFrame(openRegistration)
|
||||
}, [openRegistration])
|
||||
|
||||
const activeVideo = useMemo(() => {
|
||||
if (phase === FLOW_PHASE.JINGLE) return videos.entryJingle
|
||||
if (phase !== FLOW_PHASE.WELCOME_VIDEO) return null
|
||||
return selectedLanguage === 'en' ? videos.welcomeEn : videos.welcomeFr
|
||||
}, [phase, selectedLanguage])
|
||||
|
||||
const activeVideoClose =
|
||||
phase === FLOW_PHASE.JINGLE ? handleJingleClose : handleWelcomeVideoClose
|
||||
const skipLabel =
|
||||
phase === FLOW_PHASE.JINGLE
|
||||
? 'Passer / Skip'
|
||||
: selectedLanguage === 'en'
|
||||
? 'Skip video'
|
||||
: 'Passer la vidéo'
|
||||
const playLabel =
|
||||
phase === FLOW_PHASE.JINGLE
|
||||
? 'Lancer le jingle / Play jingle'
|
||||
: selectedLanguage === 'en'
|
||||
? 'Play video with sound'
|
||||
: 'Lire la vidéo avec le son'
|
||||
|
||||
return (
|
||||
<>
|
||||
<EntryJingle active={!isAuthenticated && !selectedLanguage} />
|
||||
<Page title="Landing Page" backgroundImg={background.homeBGWeb}>
|
||||
<View
|
||||
style={{
|
||||
@@ -109,7 +100,7 @@ export default function LandingPage() {
|
||||
}}
|
||||
>
|
||||
<View style={styles.content}>
|
||||
{!selectedLanguage && (
|
||||
{phase === FLOW_PHASE.LANGUAGE ? (
|
||||
<View style={styles.languageRow}>
|
||||
<TouchableOpacity
|
||||
style={[styles.languageButton]}
|
||||
@@ -126,14 +117,22 @@ export default function LandingPage() {
|
||||
<Text style={styles.languageText}>English</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
{selectedLanguage && welcomeVideoLoading ? (
|
||||
<ActivityIndicator color={Palette.white} size="large" />
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
</Page>
|
||||
<FullscreenIntroVideo url={videoUrl} visible={!!videoUrl} onClose={handleVideoClose} />
|
||||
{activeVideo ? (
|
||||
<FullscreenIntroVideo
|
||||
key={phase}
|
||||
url={activeVideo}
|
||||
visible
|
||||
onClose={activeVideoClose}
|
||||
contentFit="contain"
|
||||
requireAudio
|
||||
skipLabel={skipLabel}
|
||||
playLabel={playLabel}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { responsiveWidth } from 'react-native-responsive-dimensions'
|
||||
import { useGlobal } from 'reactn'
|
||||
import { icons, img } from '../../../assets'
|
||||
import PressableScale from '../../../components/PressableScale'
|
||||
import useResolvedMediaUri from '../../../hooks/useResolvedMediaUri'
|
||||
import { LIKE_TARGET, toggleProjectLike } from '../../../utils/likes'
|
||||
import { ensureAuthenticated } from '../../../utils/authRedirect'
|
||||
import { Palette, Style } from '../../../styles'
|
||||
@@ -23,6 +24,7 @@ const MusicCard = ({
|
||||
likeTarget = LIKE_TARGET.SONG,
|
||||
}) => {
|
||||
const [currentUID] = useGlobal('currentUID')
|
||||
const resolvedImageUri = useResolvedMediaUri(imageUri)
|
||||
const [selected, setSelected] = useState(false)
|
||||
const [layout, setLayout] = useState(null)
|
||||
const [menuPos, setMenuPos] = useState({
|
||||
@@ -113,9 +115,9 @@ const MusicCard = ({
|
||||
onPress={onPress}
|
||||
onLayout={(e) => setLayout(e.nativeEvent.layout)}
|
||||
>
|
||||
{imageUri ? (
|
||||
{resolvedImageUri ? (
|
||||
<ExpoImage
|
||||
source={{ uri: imageUri }}
|
||||
source={{ uri: resolvedImageUri }}
|
||||
cachePolicy="memory-disk"
|
||||
priority="high"
|
||||
contentFit="cover"
|
||||
|
||||
+19
-1
@@ -31,7 +31,7 @@ import { getRouteAfterAuthentication } from '../utils/registrationFlow'
|
||||
|
||||
const LANGUAGE_STORAGE_KEY = 'preferredLanguage'
|
||||
|
||||
const Register = () => {
|
||||
const Register = ({ navigation }) => {
|
||||
const [email, setEmail] = useState('')
|
||||
const [firstName, setFirstName] = useState('')
|
||||
const [lastName, setLastName] = useState('')
|
||||
@@ -40,6 +40,23 @@ const Register = () => {
|
||||
const [showDatePicker, setShowDatePicker] = useState(false)
|
||||
const [, setTooltip] = useGlobal('_tooltip')
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
if (navigation.canGoBack()) {
|
||||
navigation.goBack()
|
||||
return
|
||||
}
|
||||
|
||||
navigation.reset({
|
||||
index: 0,
|
||||
routes: [
|
||||
{
|
||||
name: Routes.LandingPage,
|
||||
params: { startAtLanguage: true },
|
||||
},
|
||||
],
|
||||
})
|
||||
}, [navigation])
|
||||
|
||||
const afterSocialAuth = useCallback(async (authResult) => {
|
||||
const uid = firebase.auth().currentUser?.uid
|
||||
if (!uid) throw new Error('Aucun utilisateur après connexion')
|
||||
@@ -225,6 +242,7 @@ const Register = () => {
|
||||
backgroundImg={isWeb ? background.loginBgWeb : background.homeBG}
|
||||
headerType="NAVIGATION"
|
||||
title="Inscription"
|
||||
onBackPressed={handleBack}
|
||||
>
|
||||
<View style={styles.pageContent}>
|
||||
<ItemContainer
|
||||
|
||||
@@ -36,15 +36,7 @@ export default ({ navigation }) => {
|
||||
} finally {
|
||||
navigation.reset({
|
||||
index: 0,
|
||||
routes: [
|
||||
{
|
||||
name: Routes.BottomTab,
|
||||
params: {
|
||||
screen: Routes.HomeStack,
|
||||
params: { screen: Routes.Home },
|
||||
},
|
||||
},
|
||||
],
|
||||
routes: [{ name: Routes.LandingPage }],
|
||||
})
|
||||
setIsFullyLoaded(true)
|
||||
}
|
||||
|
||||
@@ -307,7 +307,6 @@ const ComposeSong = () => {
|
||||
) : null
|
||||
}
|
||||
coinBadgeContainerStyle={styles.rightSideCredits}
|
||||
containerStyle={styles.videoQuestionPage}
|
||||
headerType="NONE"
|
||||
>
|
||||
<MusicLandHeader
|
||||
@@ -502,10 +501,6 @@ const ComposeSong = () => {
|
||||
export default ComposeSong
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
videoQuestionPage: {
|
||||
alignSelf: 'flex-start',
|
||||
marginLeft: '4%',
|
||||
},
|
||||
rightSideControl: {
|
||||
right: 24,
|
||||
left: 'auto',
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import { Platform, StyleSheet, View } from 'react-native'
|
||||
import { Platform, View } from 'react-native'
|
||||
import { responsiveHeight } from 'react-native-responsive-dimensions'
|
||||
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'
|
||||
@@ -25,7 +24,7 @@ const MAX_STEP_INDEX = 8
|
||||
const CUSTOM_SANITIZE_OPTIONS = { autoInjectPreChorus: false }
|
||||
|
||||
const CreateLyricsWithAi = () => {
|
||||
const { selectedProject, videos } = useUser()
|
||||
const { selectedProject } = useUser()
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const handleLyricsError = React.useCallback(() => {
|
||||
setSelectedIndex(7)
|
||||
@@ -386,18 +385,13 @@ const CreateLyricsWithAi = () => {
|
||||
]
|
||||
|
||||
const currentStep = steps[selectedIndex]
|
||||
const isQuestionStep = selectedIndex < MAX_STEP_INDEX
|
||||
|
||||
return (
|
||||
<Page
|
||||
headerType="NONE"
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
keyboardVerticalOffset={Platform.OS === 'ios' ? 64 : 100}
|
||||
backgroundImg={isQuestionStep ? null : background.libraryBgWeb}
|
||||
backgroundContent={
|
||||
isQuestionStep ? <BackgroundVideo source={videos?.celineWeb} /> : null
|
||||
}
|
||||
containerStyle={isQuestionStep ? styles.videoQuestionPage : undefined}
|
||||
backgroundImg={background.libraryBgWeb}
|
||||
>
|
||||
<MusicLandHeader onPressBack={onPressBack} progress={progress} />
|
||||
<View
|
||||
@@ -435,10 +429,3 @@ const CreateLyricsWithAi = () => {
|
||||
}
|
||||
|
||||
export default CreateLyricsWithAi
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
videoQuestionPage: {
|
||||
alignSelf: 'flex-end',
|
||||
marginRight: '4%',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ import useMinuit from 'react-native-minuit/src/hooks/useMinuit.js'
|
||||
import alert from '../../components/Alert'
|
||||
import GradientButton from '../../components/GradientButton'
|
||||
import ProgressBar from '../../components/ProgressBar'
|
||||
import firebase from '../../config/firebase'
|
||||
import { getFunctionsClient } from '../../config/firebase'
|
||||
import { Routes } from '../../navigation'
|
||||
import { navigate } from '../../navigation/NavigationService'
|
||||
import { useUser } from '../../providers/UserDataProvider'
|
||||
@@ -20,6 +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 CreatingLyrics = ({ active, config, selections, onErrorRedirect }) => {
|
||||
const [progress, setProgress] = useState(0)
|
||||
@@ -73,7 +74,7 @@ const CreatingLyrics = ({ active, config, selections, onErrorRedirect }) => {
|
||||
platform: Platform.OS,
|
||||
})
|
||||
const sanitizedStructure = sanitizeStructureList(config?.structure, CUSTOM_SANITIZE_OPTIONS)
|
||||
const callable = firebase.functions().httpsCallable('lyrics-generateLyrics')
|
||||
const callable = getFunctionsClient(FUNCTIONS_REGION).httpsCallable('lyrics-generateLyrics')
|
||||
const { data } = await callable({
|
||||
lang: projectLang,
|
||||
objective: config?.objective,
|
||||
|
||||
@@ -8,7 +8,7 @@ import GradientButton from '../../components/GradientButton'
|
||||
import ItemContainer from '../../components/ItemContainer/ItemContainer'
|
||||
import MusicLandHeader from '../../components/MusicLandHeader'
|
||||
import Overlay from '../../components/Overlay'
|
||||
import firebase, { projectsRef } from '../../config/firebase'
|
||||
import firebase, { getFunctionsClient, projectsRef } from '../../config/firebase'
|
||||
import { strings } from '../../constants/strings'
|
||||
import { isWeb } from '../../hooks/useLayoutType'
|
||||
import Page from '../../layouts/Page'
|
||||
@@ -31,6 +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 Lyrics = ({ navigation }) => {
|
||||
const scrollRef = useRef(null)
|
||||
@@ -239,7 +240,9 @@ const Lyrics = ({ navigation }) => {
|
||||
|
||||
// 1) Appel de la Cloud Function de modération avant tout enregistrement
|
||||
try {
|
||||
const callable = firebase.functions().httpsCallable('lyrics-analyseLyricsToxicity')
|
||||
const callable = getFunctionsClient(FUNCTIONS_REGION).httpsCallable(
|
||||
'lyrics-analyseLyricsToxicity'
|
||||
)
|
||||
const { data } = await callable({
|
||||
title: titleTrimmed,
|
||||
lyrics: normalizedNewLyrics,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Asset } from 'expo-asset'
|
||||
|
||||
import firebase from '../config/firebase'
|
||||
|
||||
const ABSOLUTE_URI_PATTERN = /^(https?:|blob:|data:)/i
|
||||
|
||||
export const resolveMediaUri = async (source) => {
|
||||
if (!source) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (typeof source === 'string') {
|
||||
const trimmedSource = source.trim()
|
||||
|
||||
if (!trimmedSource) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (ABSOLUTE_URI_PATTERN.test(trimmedSource) || trimmedSource.startsWith('/')) {
|
||||
return trimmedSource
|
||||
}
|
||||
|
||||
return firebase.storage().ref(trimmedSource).getDownloadURL()
|
||||
}
|
||||
|
||||
const asset = Asset.fromModule(source)
|
||||
|
||||
if (!asset.localUri && !asset.uri) {
|
||||
await asset.downloadAsync()
|
||||
}
|
||||
|
||||
return asset.localUri ?? asset.uri ?? null
|
||||
}
|
||||
Reference in New Issue
Block a user