Compare commits

...

10 Commits

Author SHA1 Message Date
leonm 3ba290031d feat: fix payment flow 2026-09-04 13:04:27 +02:00
leonm efbb7994f2 feat: fix clouds functions and heygen 2026-09-04 12:22:29 +02:00
leonm 7bf913e014 feat: cgu 2026-09-03 14:29:31 +02:00
leonm 7521f5939f feat: try lipsinck 2026-09-03 11:06:08 +02:00
leonm 99431f9ae4 feat: download options 2026-09-03 09:30:31 +02:00
leonm f4549b694f feat: remove publish to youtube 2026-09-02 16:08:48 +02:00
Jacques_tirot 4e14a38d4e fix: improve onboarding audio and mobile layout 2026-08-26 15:19:40 +02:00
Jacques_tirot fe51439bbe fix: enforce automatic startup jingle 2026-08-26 14:33:10 +02:00
Jacques_tirot bc7edcf3a7 feat: update onboarding and application flows 2026-08-25 15:55:11 +02:00
leonm 4fb8a3e1e0 feat: synchronise audio and video playback 2026-08-11 16:33:44 +02:00
93 changed files with 9334 additions and 3740 deletions
+6 -4
View File
@@ -32,7 +32,6 @@ import { LogBox } from 'react-native'
import CommentsBottomSheet from './src/components/bottomsheets/CommentsBottomSheet' import CommentsBottomSheet from './src/components/bottomsheets/CommentsBottomSheet'
import ShareQrModalContainer from './src/components/modal/ShareQrModalContainer' import ShareQrModalContainer from './src/components/modal/ShareQrModalContainer'
import AppDownloadBanner from './src/components/AppDownloadBanner' import AppDownloadBanner from './src/components/AppDownloadBanner'
import MobileSplashVideo from './src/components/MobileSplashVideo'
import './src/utils/Sheet' import './src/utils/Sheet'
console.disableYellowBox = true console.disableYellowBox = true
@@ -121,11 +120,15 @@ const App = () => {
useEffect(() => { useEffect(() => {
if (Platform.OS === 'web') { if (Platform.OS === 'web') {
const s = document.createElement('script') const s = document.createElement('script')
const isCompactViewport = window.innerWidth < 700
s.src = 'https://minuit.app/embed/report-a-problem.js' s.src = 'https://minuit.app/embed/report-a-problem.js'
s.async = true s.async = true
s.setAttribute('data-project-id', 'playbackproduction') s.setAttribute('data-project-id', 'playbackproduction')
s.setAttribute('data-position', 'right') s.setAttribute('data-position', isCompactViewport ? 'custom' : 'right')
s.setAttribute('data-offset', '16') s.setAttribute('data-offset', isCompactViewport ? '12' : '16')
if (isCompactViewport) {
s.setAttribute('data-top', '72')
}
s.setAttribute('data-primary-color', '#FB68A8') s.setAttribute('data-primary-color', '#FB68A8')
s.setAttribute('data-bg-primary-color', '#0E0E12') s.setAttribute('data-bg-primary-color', '#0E0E12')
s.setAttribute('data-bg-secondary-color', '#1D1825') s.setAttribute('data-bg-secondary-color', '#1D1825')
@@ -208,7 +211,6 @@ const App = () => {
<CommentsBottomSheet /> <CommentsBottomSheet />
<ShareQrModalContainer /> <ShareQrModalContainer />
<AppDownloadBanner /> <AppDownloadBanner />
<MobileSplashVideo />
{/* </AppLayout> */} {/* </AppLayout> */}
</MenuProvider> </MenuProvider>
</Providers> </Providers>
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <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"/> <foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon> </adaptive-icon>
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <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"/> <foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon> </adaptive-icon>
Binary file not shown.
+82
View File
@@ -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.
+158
View File
@@ -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
+540
View File
@@ -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.
+21
View File
@@ -0,0 +1,21 @@
# This file specifies files that are *not* uploaded to Google Cloud
# using gcloud. It follows the same syntax as .gitignore, with the addition of
# "#!include" directives (which insert the entries of the given .gitignore-style
# file at that point).
#
# For more information, run:
# $ gcloud topic gcloudignore
#
.gcloudignore
# If you would like to upload your .git directory, .gitignore file or files
# from your .gitignore file, remove the corresponding line
# below:
.git
.gitignore
node_modules
.env
.env.*
!.env.example
.secret.local
serviceAccountKey.json
+72 -12
View File
@@ -6,8 +6,64 @@ const { Buffer } = require('buffer')
const { setTimeout } = require('timers/promises') const { setTimeout } = require('timers/promises')
// --- CONFIGURATION --- // --- CONFIGURATION ---
// Plus intelligente que le Flash original, ultra rapide, et stable sur l'API. // Utilise des versions stables explicites en production. L'alias `latest` peut
const TEXT_MODEL_NAME = 'gemini-flash-latest' // changer sans déploiement et pointer temporairement vers un modèle saturé.
const TEXT_MODEL_NAMES = ['gemini-3.6-flash', 'gemini-3.5-flash', 'gemini-3.1-flash-lite']
const MODERATION_MODEL_NAMES = ['gemini-3.1-flash-lite', 'gemini-3.6-flash', 'gemini-3.5-flash']
const RETRYABLE_STATUS_CODES = new Set([404, 429, 500, 502, 503, 504])
const getErrorStatus = (error) => {
const status = Number(error?.status || error?.statusCode || error?.response?.status)
return Number.isFinite(status) ? status : null
}
const canTryFallbackModel = (error) => {
const status = getErrorStatus(error)
if (status && RETRYABLE_STATUS_CODES.has(status)) return true
const message = String(error?.message || '').toLowerCase()
return (
message.includes('high demand') ||
message.includes('overloaded') ||
message.includes('temporarily unavailable') ||
message.includes('model not found')
)
}
const generateWithModelFallback = async ({ ai, modelNames, label, request }) => {
let lastError = null
for (let index = 0; index < modelNames.length; index++) {
const modelName = modelNames[index]
try {
console.log(`🤖 [${label}] Modèle ${modelName} (${index + 1}/${modelNames.length})`)
const response = await ai.generate({
...request,
model: googleAI.model(modelName),
})
if (!response?.output) {
throw new Error(`${label}: réponse structurée vide pour ${modelName}.`)
}
return response.output
} catch (error) {
lastError = error
const hasFallback = index < modelNames.length - 1
if (!hasFallback || !canTryFallbackModel(error)) throw error
console.warn(`⚠️ [${label}] ${modelName} indisponible, essai du modèle suivant`, {
status: getErrorStatus(error),
message: error?.message,
})
await setTimeout(500 * (index + 1))
}
}
throw lastError || new Error(`${label}: aucun modèle disponible.`)
}
const IMAGE_MODEL_NAME = 'gemini-3-pro-image' const IMAGE_MODEL_NAME = 'gemini-3-pro-image'
// --- SINGLETON PATTERN (WARM START) --- // --- SINGLETON PATTERN (WARM START) ---
@@ -35,21 +91,23 @@ exports.generateAI = async ({ system = '', prompt = '', schema }) => {
throw new Error('Vous devez spécifier un prompt pour effectuer cette action.') throw new Error('Vous devez spécifier un prompt pour effectuer cette action.')
} }
console.log(`🧠 [generateAI] Start (${TEXT_MODEL_NAME})`) console.log(`🧠 [generateAI] Start (${TEXT_MODEL_NAMES.join(' -> ')})`)
const startedAt = Date.now() const startedAt = Date.now()
try { try {
const { output } = await ai.generate({ return await generateWithModelFallback({
model: googleAI.model(TEXT_MODEL_NAME), ai,
modelNames: TEXT_MODEL_NAMES,
label: 'generateAI',
request: {
system, system,
prompt, prompt,
output: { schema }, output: { schema },
config: { config: {
temperature: 0.7, // Créativité équilibrée temperature: 0.7, // Créativité équilibrée
}, },
},
}) })
return output
} catch (error) { } catch (error) {
console.error('❌ [generateAI] Error:', error.message) console.error('❌ [generateAI] Error:', error.message)
throw error throw error
@@ -129,12 +187,15 @@ ${lyricsText}
""" """
` `
console.log(`🛡️ [analyseLyrics] Start (${TEXT_MODEL_NAME})`) console.log(`🛡️ [analyseLyrics] Start (${MODERATION_MODEL_NAMES.join(' -> ')})`)
const startedAt = Date.now() const startedAt = Date.now()
try { try {
const { output } = await ai.generate({ const output = await generateWithModelFallback({
model: googleAI.model(TEXT_MODEL_NAME), ai,
modelNames: MODERATION_MODEL_NAMES,
label: 'analyseLyrics',
request: {
system, system,
prompt: userPrompt, prompt: userPrompt,
output: { schema: moderationSchema }, output: { schema: moderationSchema },
@@ -153,10 +214,9 @@ ${lyricsText}
{ category: 'HARM_CATEGORY_HARASSMENT', threshold: 'BLOCK_NONE' }, { category: 'HARM_CATEGORY_HARASSMENT', threshold: 'BLOCK_NONE' },
], ],
}, },
},
}) })
if (!output) throw new Error("Échec de l'analyse de modération.")
// Correction de cohérence // Correction de cohérence
if (output.blocked) { if (output.blocked) {
output.flagged = true output.flagged = true
-1
View File
@@ -57,6 +57,5 @@ exports.notifications = require('./src/notifications')
exports.rankings = require('./src/rankings') exports.rankings = require('./src/rankings')
exports.payouts = require('./src/payouts') exports.payouts = require('./src/payouts')
exports.subscription = require('./src/subscription') exports.subscription = require('./src/subscription')
exports.youtube = require('./src/youtube')
exports.orders = require('./src/orders') exports.orders = require('./src/orders')
exports.cron = require('./src/cron') exports.cron = require('./src/cron')
+5763 -123
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -26,7 +26,6 @@
"firebase-functions": "^6.0.1", "firebase-functions": "^6.0.1",
"fluent-ffmpeg": "^2.1.3", "fluent-ffmpeg": "^2.1.3",
"genkit": "^1.18.0", "genkit": "^1.18.0",
"googleapis": "^131.0.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"resend": "^6.3.0", "resend": "^6.3.0",
"sharp": "^0.33.4", "sharp": "^0.33.4",
@@ -34,7 +33,8 @@
}, },
"devDependencies": { "devDependencies": {
"eslint": "^8.15.0", "eslint": "^8.15.0",
"eslint-config-google": "^0.14.0" "eslint-config-google": "^0.14.0",
"firebase-tools": "^15.28.1"
}, },
"private": true "private": true
} }
+1 -1
View File
@@ -10,7 +10,6 @@ const path = require('path')
const { ALERT_TYPE, refList } = require('../index') const { ALERT_TYPE, refList } = require('../index')
const { sendNotification } = require('./notifications') const { sendNotification } = require('./notifications')
const bucket = admin.storage().bucket()
const COVER_TEMPLATE = 'MUSICLAND_PLAYBACKER' const COVER_TEMPLATE = 'MUSICLAND_PLAYBACKER'
const COVER_OPTION_ID = 'musicland-playbacker' const COVER_OPTION_ID = 'musicland-playbacker'
const COVER_SIZE = 1024 const COVER_SIZE = 1024
@@ -147,6 +146,7 @@ async function buildProfileOverlay(profilePictureURL) {
} }
async function uploadCover(buffer, targetPath) { async function uploadCover(buffer, targetPath) {
const bucket = admin.storage().bucket()
const token = crypto.randomUUID() const token = crypto.randomUUID()
const file = bucket.file(targetPath) const file = bucket.file(targetPath)
+18
View File
@@ -17,6 +17,7 @@ const projectsRef = db.collection('projects')
const REGION = process.env.FIREBASE_REGION || 'europe-west1' const REGION = process.env.FIREBASE_REGION || 'europe-west1'
const HEYGEN_BASE_URL = 'https://api.heygen.com/v3' const HEYGEN_BASE_URL = 'https://api.heygen.com/v3'
const HEYGEN_REQUEST_TIMEOUT_MS = 45000
const PLAYBACK_STATUS = { const PLAYBACK_STATUS = {
GENERATING: 'GENERATING', GENERATING: 'GENERATING',
DRAFT_READY: 'DRAFT_READY', DRAFT_READY: 'DRAFT_READY',
@@ -120,6 +121,7 @@ const findProjectForWebhook = async ({ callbackId, videoId }) => {
const fetchCanonicalVideo = async (videoId) => { const fetchCanonicalVideo = async (videoId) => {
const response = await axios.get(`${HEYGEN_BASE_URL}/videos/${videoId}`, { const response = await axios.get(`${HEYGEN_BASE_URL}/videos/${videoId}`, {
headers: getHeygenHeaders(), headers: getHeygenHeaders(),
timeout: HEYGEN_REQUEST_TIMEOUT_MS,
}) })
return response?.data?.data || null return response?.data?.data || null
@@ -176,6 +178,13 @@ exports.startPlaybackGeneration = onCall(
const callbackUrl = buildCallbackUrl() const callbackUrl = buildCallbackUrl()
const title = trimString(project?.title) || `Playback ${projectId}` const title = trimString(project?.title) || `Playback ${projectId}`
logger.info('[HeyGen] startPlaybackGeneration request', {
projectId,
uid,
hasSongUrl: Boolean(songUrl),
hasPhotoUrl: Boolean(photoUrl),
})
await projectRef.set( await projectRef.set(
{ {
playbackProvider: 'heygen', playbackProvider: 'heygen',
@@ -211,6 +220,7 @@ exports.startPlaybackGeneration = onCall(
}, },
{ {
headers: getHeygenHeaders(), headers: getHeygenHeaders(),
timeout: HEYGEN_REQUEST_TIMEOUT_MS,
} }
) )
@@ -227,6 +237,12 @@ exports.startPlaybackGeneration = onCall(
{ merge: true } { merge: true }
) )
logger.info('[HeyGen] generation accepted', {
projectId,
uid,
videoId,
})
return { return {
jobId: videoId, jobId: videoId,
callbackId, callbackId,
@@ -284,6 +300,8 @@ exports.validatePlaybackDraft = onCall({ region: REGION, timeoutSeconds: 540 },
playbackJobId: null, playbackJobId: null,
playbackCallbackId: null, playbackCallbackId: null,
playbackError: null, playbackError: null,
playbackPublishedOnMusicLand: false,
playbackPublishedOnMusicLandAt: null,
updatedAt: FieldValue.serverTimestamp(), updatedAt: FieldValue.serverTimestamp(),
}, },
{ merge: true } { merge: true }
+54 -12
View File
@@ -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 { GEMINI_API_KEY, SUNO_API_KEY } = require('../config/secrets')
const axios = require('axios') const axios = require('axios')
const { FieldValue } = require('firebase-admin/firestore') const { FieldValue } = require('firebase-admin/firestore')
const { refList } = require('../index') const { REGION, refList } = require('../index')
// --- CONSTANTES DE STRUCTURE --- // --- CONSTANTES DE STRUCTURE ---
// On garde ces mappings car ils sont utiles pour normaliser l'input utilisateur // On garde ces mappings car ils sont utiles pour normaliser l'input utilisateur
@@ -59,6 +59,13 @@ const STRUCTURE_ALIASES = {
soft_transition: 'transition_douce', soft_transition: 'transition_douce',
} }
const LYRICS_FUNCTION_OPTIONS = {
region: REGION,
cors: true,
invoker: 'public',
secrets: [GEMINI_API_KEY],
}
// --- UTILITAIRES --- // --- UTILITAIRES ---
const normalizeStructureValue = (value) => { const normalizeStructureValue = (value) => {
@@ -137,7 +144,7 @@ const buildModerationBrief = ({
// --- GENERATION DE PAROLES (MAIN) --- // --- GENERATION DE PAROLES (MAIN) ---
exports.generateLyrics = onCall({ secrets: [GEMINI_API_KEY] }, async ({ auth = {}, data = {} }) => { exports.generateLyrics = onCall(LYRICS_FUNCTION_OPTIONS, async ({ auth = {}, data = {} }) => {
try { try {
const { const {
lang: rawLang, lang: rawLang,
@@ -197,9 +204,9 @@ exports.generateLyrics = onCall({ secrets: [GEMINI_API_KEY] }, async ({ auth = {
} }
} catch (moderationError) { } catch (moderationError) {
if (moderationError instanceof HttpsError) throw moderationError if (moderationError instanceof HttpsError) throw moderationError
console.error('⚠️ Moderation check error (fail open)', moderationError) console.error('⚠️ Moderation check error (fail closed)', moderationError)
// On continue si l'appel modération fail (fail open) ou on throw (fail closed) selon ta politique. // La modération essaie plusieurs modèles stables avant d'arriver ici.
// Ici je fail closed par sécurité pour une app publique. // Si tous sont indisponibles, on bloque par sécurité pour une app publique.
throw new HttpsError('internal', 'Vérification de sécurité indisponible.') throw new HttpsError('internal', 'Vérification de sécurité indisponible.')
} }
@@ -252,7 +259,7 @@ ${structureTags || fallbackStructureTags}
0. ${languageInstruction} 0. ${languageInstruction}
1. Génère un **Titre** percutant (moins de 6 mots). 1. Génère un **Titre** percutant (moins de 6 mots).
2. Pour chaque section <SECTION>, écris le contenu adapté : 2. Pour chaque section <SECTION>, écris le contenu adapté :
- Si c'est "Instrumental" ou "Solo" : laisse le champ 'lyrics' vide ou mets une brève indication d'ambiance entre parenthèses ex: "(Solo de guitare déchirant)". - Si c'est "Instrumental", "Intro", "Pont" ou "Solo" : le champ 'lyrics' doit être strictement vide. N'ajoute aucune indication entre parenthèses.
- Si c'est "Couplet/Refrain" : Écris 4 à 12 vers. - Si c'est "Couplet/Refrain" : Écris 4 à 12 vers.
3. **IMPORTANT** : Le style est "${style}". Assure-toi que le vocabulaire et le rythme collent parfaitement à ce genre. 3. **IMPORTANT** : Le style est "${style}". Assure-toi que le vocabulaire et le rythme collent parfaitement à ce genre.
</CONSIGNES_GENERATION> </CONSIGNES_GENERATION>
@@ -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 { try {
const requestSchema = z.object({ const requestSchema = z.object({
title: z.string().optional(), title: z.string().optional(),
@@ -421,15 +428,16 @@ async function getSunoTimestamps(projectId) {
const docSnap = await refList.projects.doc(projectId).get() const docSnap = await refList.projects.doc(projectId).get()
if (!docSnap.exists) throw new Error('Projet introuvable') if (!docSnap.exists) throw new Error('Projet introuvable')
const { sunoTaskId, songIndex } = docSnap.data() const { songSunoTaskId, sunoAudioId, songIndex } = docSnap.data()
if (!sunoTaskId) throw new Error('TaskId manquant') if (!songSunoTaskId) throw new Error('TaskId de la piste manquant')
if (!sunoAudioId) throw new Error('AudioId Suno manquant')
if (songIndex === undefined || songIndex < 0) throw new Error('musicIndex invalide') if (songIndex === undefined || songIndex < 0) throw new Error('musicIndex invalide')
console.log('🔎 getSunoTimestamps', { sunoTaskId, songIndex }) console.log('🔎 getSunoTimestamps', { songSunoTaskId, sunoAudioId, songIndex })
const response = await axios.post( const response = await axios.post(
`${SUNO_API_BASE}${SUNO_TIMESTAMPED_LYRICS_PATH}`, `${SUNO_API_BASE}${SUNO_TIMESTAMPED_LYRICS_PATH}`,
{ taskId: sunoTaskId, musicIndex: songIndex }, { taskId: songSunoTaskId, audioId: sunoAudioId },
{ {
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -439,11 +447,32 @@ async function getSunoTimestamps(projectId) {
} }
) )
const dataToReturn = response.data?.data || response.data || {} if (response.data?.code !== 200) {
throw new Error(response.data?.msg || 'Récupération des timestamps refusée par Suno')
}
const dataToReturn = response.data?.data
if (!Array.isArray(dataToReturn?.alignedWords) || dataToReturn.alignedWords.length === 0) {
throw new Error('Aucune parole synchronisée retournée par Suno')
}
const failedWordCount = dataToReturn.alignedWords.filter(
(word) => word?.success === false
).length
const hootCer = Number(dataToReturn.hootCer)
await refList.projects.doc(projectId).set( await refList.projects.doc(projectId).set(
{ {
musicTimestamps: { [songIndex]: dataToReturn }, musicTimestamps: { [songIndex]: dataToReturn },
lyricsSyncStatus: 'READY',
lyricsSyncError: FieldValue.delete(),
lyricsSyncDiagnostics: {
alignedWordCount: dataToReturn.alignedWords.length,
failedWordCount,
successfulWordRatio:
(dataToReturn.alignedWords.length - failedWordCount) /
dataToReturn.alignedWords.length,
hootCer: Number.isFinite(hootCer) ? hootCer : null,
},
updatedAt: FieldValue.serverTimestamp(), updatedAt: FieldValue.serverTimestamp(),
}, },
{ merge: true } { merge: true }
@@ -452,6 +481,19 @@ async function getSunoTimestamps(projectId) {
return { success: true } return { success: true }
} catch (error) { } catch (error) {
console.error('❌ getSunoTimestamps Error:', error.message) console.error('❌ getSunoTimestamps Error:', error.message)
if (projectId) {
await refList.projects
.doc(projectId)
.set(
{
lyricsSyncStatus: 'FAILED',
lyricsSyncError: error.message,
updatedAt: FieldValue.serverTimestamp(),
},
{ merge: true }
)
.catch(() => {})
}
return { return {
success: false, success: false,
error: { message: error.message, type: 'INTERNAL_ERROR' }, error: { message: error.message, type: 'INTERNAL_ERROR' },
+143 -84
View File
@@ -2,7 +2,7 @@ const { onCall, onRequest, HttpsError } = require('firebase-functions/v2/https')
const axios = require('axios') const axios = require('axios')
const admin = require('firebase-admin') const admin = require('firebase-admin')
const { FieldValue } = require('firebase-admin/firestore') const { FieldValue } = require('firebase-admin/firestore')
const { logger } = require('firebase-functions/logger') const logger = require('firebase-functions/logger')
const { pipeline } = require('stream/promises') const { pipeline } = require('stream/promises')
const { randomUUID } = require('crypto') const { randomUUID } = require('crypto')
const { ALERT_TYPE, refList } = require('../index') const { ALERT_TYPE, refList } = require('../index')
@@ -19,6 +19,14 @@ const {
const MUSIC_GENERATION_CREDIT_COST = 8 const MUSIC_GENERATION_CREDIT_COST = 8
const MUSIC_REFUND_SOURCE = 'music_generation_refund' const MUSIC_REFUND_SOURCE = 'music_generation_refund'
const SUNO_LYRICS_PROMPT_MAX_LENGTH = 5000
const SUNO_TITLE_MAX_LENGTH = 100
const getSunoAuthorizationHeader = () => {
const apiKey = String(SUNO_API_KEY.value() || '').trim()
if (!apiKey) throw new Error('SUNO_API_KEY_EMPTY')
return `Bearer ${apiKey}`
}
// ========================================== // ==========================================
// 1. DICTIONNAIRES DE TRADUCTION (FRONT -> SUNO) // 1. DICTIONNAIRES DE TRADUCTION (FRONT -> SUNO)
@@ -161,30 +169,54 @@ function parseVoiceTags(voiceSelections = []) {
/** /**
* Convertit les phrases de structure custom en Balises Suno * Convertit les phrases de structure custom en Balises Suno
*/ */
function mapStructureToTag(description) { const STRUCTURE_TAGS = {
const d = description.toLowerCase() couplet: '[Verse]',
refrain: '[Chorus]',
short_intro: '[Short Instrumental Intro]',
long_intro: '[Long Instrumental Intro]',
pre_refrain_instrumental: '[Instrumental Pre-Chorus]',
pont: '[Instrumental Bridge]',
solo_de_guitare: '[Guitar Solo]',
solo_de_guitare_electrique: '[Electric Guitar Solo]',
solo_de_batterie: '[Drum Solo]',
solo_de_saxophone: '[Saxophone Solo]',
solo_de_violon: '[Violin Solo]',
break: '[Instrumental Break]',
interlude: '[Instrumental Interlude]',
interlude_melodique: '[Melodic Instrumental Interlude]',
final_apogee: '[Big Finish]',
arret_net: '[Sudden End]',
fade_out: '[Fade Out]',
transition_douce: '[Fade to Silence]',
}
if (d.includes('intro') && d.includes('court')) return '[Short Intro]' const INSTRUMENTAL_STRUCTURE_TYPES = new Set([
if (d.includes('intro')) return '[Intro]' 'short_intro',
if (d.includes('pré-refrain')) return '[Pre-Chorus]' 'long_intro',
if (d.includes('solo guitare électrique')) return '[Electric Guitar Solo]' 'pre_refrain_instrumental',
if (d.includes('solo guitare')) return '[Guitar Solo]' 'pont',
if (d.includes('solo batterie')) return '[Drum Solo]' 'solo_de_guitare',
if (d.includes('solo saxo')) return '[Saxophone Solo]' 'solo_de_guitare_electrique',
if (d.includes('solo violon')) return '[Violin Solo]' 'solo_de_batterie',
if (d.includes('interlude')) return '[Instrumental Interlude]' 'solo_de_saxophone',
if (d.includes('apogée') || d.includes('finition')) return '[Big Finish]' 'solo_de_violon',
if (d.includes('arrêt net')) return '[Sudden End]' 'break',
if (d.includes('baissant le volume') || d.includes('fade')) return '[Fade Out]' 'interlude',
if (d.includes('silence')) return '[Fade to Silence]' 'interlude_melodique',
if (d.includes('break') || d.includes('pause')) return '[Break]' 'final_apogee',
'arret_net',
'fade_out',
'transition_douce',
])
// Mapping standard des types lyrics function mapStructureToTag(type) {
if (d === 'couplet' || d === 'verse') return '[Verse]' return (
if (d === 'refrain' || d === 'chorus') return '[Chorus]' STRUCTURE_TAGS[
if (d === 'pont' || d === 'bridge') return '[Bridge]' String(type || '')
.trim()
return null // Pas de tag trouvé .toLowerCase()
] || null
)
} }
// ========================================== // ==========================================
@@ -244,44 +276,24 @@ function buildFormattedLyrics(lyricsArray) {
if (!Array.isArray(lyricsArray) || lyricsArray.length === 0) return '' if (!Array.isArray(lyricsArray) || lyricsArray.length === 0) return ''
const formattedLines = lyricsArray.map((section) => { const formattedLines = lyricsArray.map((section) => {
// Le frontend peut envoyer soit { type: "...", lyrics: "..." } soit juste une string description pour les instrumentaux const type = String(section?.type || '')
const typeOrDescription = section.type || section.description || '' .trim()
const textContent = section.lyrics || '' .toLowerCase()
const textContent = typeof section?.lyrics === 'string' ? section.lyrics.trim() : ''
const tag = mapStructureToTag(type)
// Essayer de trouver un tag spécial (ex: "Introduction instrumentale")
let tag = mapStructureToTag(typeOrDescription)
// Fallback si pas de tag spécial trouvé mais type standard
if (!tag) { if (!tag) {
if (typeOrDescription.toLowerCase().includes('couplet')) tag = '[Verse]' throw new HttpsError('invalid-argument', `Structure musicale non reconnue : ${type}.`)
else if (typeOrDescription.toLowerCase().includes('refrain')) tag = '[Chorus]'
else tag = `[${typeOrDescription}]` // Fallback générique
} }
// Si c'est une section instrumentale (pas de lyrics) if (INSTRUMENTAL_STRUCTURE_TYPES.has(type) || !textContent) {
if (!textContent.trim()) {
return `\n${tag}\n` return `\n${tag}\n`
} }
return `\n${tag}\n${textContent.trim()}` return `\n${tag}\n${textContent}`
}) })
// Sécurité: Ajouter Intro et Outro si absents (Suno best practice) return formattedLines.join('\n').trim()
const fullText = formattedLines.join('\n')
let finalPrompt = fullText
if (!fullText.includes('[Intro]')) {
finalPrompt = '[Intro]\n' + finalPrompt
}
if (
!fullText.includes('[Outro]') &&
!fullText.includes('[Fade Out]') &&
!fullText.includes('[Sudden End]')
) {
finalPrompt = finalPrompt + '\n\n[Outro]'
}
return finalPrompt.trim()
} }
// ========================================== // ==========================================
@@ -324,10 +336,14 @@ const parseSunoCallbackPayload = (rawBody = {}) => {
return { code, status, taskId, tracks } return { code, status, taskId, tracks }
} }
const extractAudioUrlsFromTracks = (tracks = []) => const extractGeneratedTracks = (tracks = []) =>
tracks tracks
.map((t) => t.audio_url || t.audioUrl || t.stream_audio_url || t.streamAudioUrl) .map((track) => ({
.filter(Boolean) audioId: typeof track?.id === 'string' ? track.id.trim() : '',
audioUrl: typeof track?.audio_url === 'string' ? track.audio_url.trim() : '',
durationS: Number(track?.duration),
}))
.filter((track) => track.audioId && track.audioUrl)
.slice(0, 2) .slice(0, 2)
const fetchProjectByTaskId = async (taskId) => { const fetchProjectByTaskId = async (taskId) => {
@@ -399,14 +415,16 @@ async function markProjectMusicFailure(projectId, error) {
reason: sunoMessage, reason: sunoMessage,
}) })
} }
await docRef.set( const failureUpdate = {
{
musicStatus: 'FAILED', musicStatus: 'FAILED',
musicError: errorPayload, musicError: errorPayload,
updatedAt: FieldValue.serverTimestamp(), updatedAt: FieldValue.serverTimestamp(),
}, }
{ merge: true } if (refundResult?.orderId) {
) failureUpdate.musicCreditsRefunded = true
failureUpdate.musicCreditsRefundOrderId = refundResult.orderId
}
await docRef.set(failureUpdate, { merge: true })
// (Notification logic here...) // (Notification logic here...)
} catch (err) { } catch (err) {
console.error('Error marking failure', err) console.error('Error marking failure', err)
@@ -437,31 +455,62 @@ const downloadTrackToStorage = async (url, { userId, projectId, taskId, bucket,
} }
} }
const saveTracksToStorage = async (audioUrls, meta) => { const saveTracksToStorage = async (tracks, meta) => {
if (!audioUrls?.length) return [] if (!tracks?.length) return []
const bucket = admin.storage().bucket() const bucket = admin.storage().bucket()
const results = await Promise.all( const results = await Promise.all(
audioUrls.map((url, index) => downloadTrackToStorage(url, { ...meta, bucket, index })) tracks.map(async (track, index) => {
const stored = await downloadTrackToStorage(track.audioUrl, { ...meta, bucket, index })
if (!stored) return null
return {
url: stored.url,
audioId: track.audioId,
durationS: Number.isFinite(track.durationS) && track.durationS > 0 ? track.durationS : null,
}
})
) )
return results.filter(Boolean).map((entry) => entry.url) return results.filter(Boolean)
} }
const mergeMusicUrls = async (projectRef, newUrls) => { const saveGeneratedTracks = async (projectRef, tracks, taskId) => {
const sanitized = sanitizeMusicUrls(newUrls) const projectData = (await projectRef.get())?.data() || {}
let existing = [] const existingUrls = sanitizeMusicUrls(projectData.musicUrls)
try { const existingAudioIds = Array.isArray(projectData.musicAudioIds) ? projectData.musicAudioIds : []
existing = sanitizeMusicUrls((await projectRef.get())?.data()?.musicUrls) const existingDurations = Array.isArray(projectData.musicDurations)
} catch (e) { } ? projectData.musicDurations
const final = [...new Set([...existing, ...sanitized])] : []
const existingTaskIds = Array.isArray(projectData.musicTaskIds) ? projectData.musicTaskIds : []
const mergedTracks = existingUrls.map((url, index) => ({
url,
audioId: existingAudioIds[index] || null,
durationS: existingDurations[index] || null,
taskId: existingTaskIds[index] || null,
}))
const knownAudioIds = new Set(mergedTracks.map((track) => track.audioId).filter(Boolean))
tracks.forEach((track) => {
if (knownAudioIds.has(track.audioId)) return
mergedTracks.push({ ...track, taskId })
knownAudioIds.add(track.audioId)
})
const musicUrls = mergedTracks.map((track) => track.url)
const musicAudioIds = mergedTracks.map((track) => track.audioId)
const musicDurations = mergedTracks.map((track) => track.durationS)
const musicTaskIds = mergedTracks.map((track) => track.taskId)
await projectRef.set( await projectRef.set(
{ {
musicStatus: 'GENERATED', musicStatus: 'GENERATED',
musicUrls: final, musicUrls,
musicAudioIds,
musicDurations,
musicTaskIds,
musicError: FieldValue.delete(), musicError: FieldValue.delete(),
}, },
{ merge: true } { merge: true }
) )
return final return musicUrls
} }
// ========================================== // ==========================================
@@ -478,7 +527,7 @@ exports.generateMusic = onCall({ secrets: [SUNO_API_KEY] }, async ({ data = {} }
songStyle = '', // "Upbeat" songStyle = '', // "Upbeat"
voice = [], // ["Une voix féminine...", "Un cri brut..."] (array ou objet) voice = [], // ["Une voix féminine...", "Un cri brut..."] (array ou objet)
instruments = [], // ["Piano", "Violon"] instruments = [], // ["Piano", "Violon"]
rhythm = '', // "Très rapide" tempo = '', // "Très rapide"
// Les champs suivants (step 1) ne sont plus utilisés dans le style Suno direct pour éviter le bruit, // Les champs suivants (step 1) ne sont plus utilisés dans le style Suno direct pour éviter le bruit,
// mais ont déjà servi à générer les paroles (lyrics). // mais ont déjà servi à générer les paroles (lyrics).
// audience, context, objective, etc. // audience, context, objective, etc.
@@ -506,18 +555,27 @@ exports.generateMusic = onCall({ secrets: [SUNO_API_KEY] }, async ({ data = {} }
songStyle, songStyle,
voiceData, voiceData,
instruments, instruments,
rhythm, rhythm: tempo,
}) })
// 2. Construction du PROMPT (Paroles + Structure) // 2. Construction du PROMPT (Paroles + Structure)
const formattedPrompt = buildFormattedLyrics(lyrics) const formattedPrompt = buildFormattedLyrics(lyrics)
const safeTitle = clampLen(title, 80) if (!formattedPrompt) {
throw new HttpsError('invalid-argument', 'Les paroles à mettre en musique sont vides.')
}
if (formattedPrompt.length > SUNO_LYRICS_PROMPT_MAX_LENGTH) {
throw new HttpsError(
'invalid-argument',
`Les paroles dépassent la limite de ${SUNO_LYRICS_PROMPT_MAX_LENGTH} caractères du modèle ${SUNO_MODEL}.`
)
}
const safeTitle = clampLen(title, SUNO_TITLE_MAX_LENGTH)
// 3. Détection du genre vocal pour le param vocalGender (optimisation v3.5) // 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 // On regarde si on trouve 'male' ou 'female' dans les tags générés
let vGender = null let vGender = null
if (optimizedStyle.includes('Female')) vGender = 'female' if (optimizedStyle.includes('Female')) vGender = 'f'
else if (optimizedStyle.includes('Male')) vGender = 'male' else if (optimizedStyle.includes('Male')) vGender = 'm'
console.log('🎵 [Suno Optimisation] Result:', { console.log('🎵 [Suno Optimisation] Result:', {
style: optimizedStyle, style: optimizedStyle,
@@ -531,7 +589,7 @@ exports.generateMusic = onCall({ secrets: [SUNO_API_KEY] }, async ({ data = {} }
customMode: true, customMode: true,
instrumental: false, instrumental: false,
model: SUNO_MODEL || 'chirp-v3-5', // Toujours viser le dernier modèle model: SUNO_MODEL || 'chirp-v3-5', // Toujours viser le dernier modèle
prompt: clampLen(formattedPrompt, 3000), prompt: formattedPrompt,
title: safeTitle, title: safeTitle,
style: optimizedStyle, style: optimizedStyle,
callBackUrl: SUNO_CALLBACK_URL || '', callBackUrl: SUNO_CALLBACK_URL || '',
@@ -542,7 +600,7 @@ exports.generateMusic = onCall({ secrets: [SUNO_API_KEY] }, async ({ data = {} }
const response = await axios.post(`${SUNO_API_BASE}${SUNO_API_PATH}`, payload, { const response = await axios.post(`${SUNO_API_BASE}${SUNO_API_PATH}`, payload, {
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: `Bearer ${SUNO_API_KEY.value()}`, Authorization: getSunoAuthorizationHeader(),
}, },
}) })
@@ -562,7 +620,7 @@ exports.generateMusic = onCall({ secrets: [SUNO_API_KEY] }, async ({ data = {} }
console.error('❌ Erreur generateMusic:', error) console.error('❌ Erreur generateMusic:', error)
try { try {
await markProjectMusicFailure(data?.projectId, error) await markProjectMusicFailure(data?.projectId, error)
} catch (e) { } } catch (e) {}
const status = error?.response?.status || 'INTERNAL_ERROR' const status = error?.response?.status || 'INTERNAL_ERROR'
const message = error?.response?.data?.msg || error?.message || 'Erreur Suno' const message = error?.response?.data?.msg || error?.message || 'Erreur Suno'
@@ -576,7 +634,7 @@ exports.getSunoStatus = onCall({ secrets: [SUNO_API_KEY] }, async ({ data = {} }
if (!taskId) throw new Error('TaskId manquant') if (!taskId) throw new Error('TaskId manquant')
try { try {
const response = await axios.get(`${SUNO_API_BASE}${SUNO_STATUS_PATH}?taskId=${taskId}`, { const response = await axios.get(`${SUNO_API_BASE}${SUNO_STATUS_PATH}?taskId=${taskId}`, {
headers: { Authorization: `Bearer ${SUNO_API_KEY.value()}` }, headers: { Authorization: getSunoAuthorizationHeader() },
timeout: 30000, timeout: 30000,
}) })
return { return {
@@ -607,12 +665,13 @@ exports.sunoCallback = onRequest({ methods: ['POST'], memory: '1GiB' }, async (r
const { projectId, projectData, projectRef } = await fetchProjectByTaskId(taskId) const { projectId, projectData, projectRef } = await fetchProjectByTaskId(taskId)
const { userId, projectTitle } = formatProjectMeta(projectData) const { userId, projectTitle } = formatProjectMeta(projectData)
const storedUrls = await saveTracksToStorage(extractAudioUrlsFromTracks(tracks), { const storedTracks = await saveTracksToStorage(extractGeneratedTracks(tracks), {
userId, userId,
projectId, projectId,
taskId, taskId,
}) })
const musicUrls = await mergeMusicUrls(projectRef, storedUrls) if (!storedTracks.length) throw new Error('SUNO_TRACK_DOWNLOAD_FAILED')
const musicUrls = await saveGeneratedTracks(projectRef, storedTracks, taskId)
if (userId) { if (userId) {
try { try {
+6 -1
View File
@@ -39,7 +39,12 @@ exports.onProjectWritten = onDocumentWritten(PROJECT_FUNCTION_OPTIONS, async (pr
} else if (!beforeData) { } else if (!beforeData) {
//song create //song create
} else { } else {
if (!beforeData?.songUrl && afterData?.songUrl) { const selectedSongChanged =
!!afterData?.songUrl &&
(beforeData?.songUrl !== afterData.songUrl ||
beforeData?.sunoAudioId !== afterData?.sunoAudioId)
if (selectedSongChanged) {
await getSunoTimestamps(projectId) await getSunoTimestamps(projectId)
await refList.projects.doc(projectId).update({ hasSong: true }) await refList.projects.doc(projectId).update({ hasSong: true })
} }
+5 -2
View File
@@ -36,9 +36,12 @@ exports.snapshotMonthlyTopSongs = onSchedule(
const { scheduleTime } = event const { scheduleTime } = event
const context = buildMonthContext(scheduleTime ? new Date(scheduleTime) : new Date()) const context = buildMonthContext(scheduleTime ? new Date(scheduleTime) : new Date())
const topProjectsSnap = await refList.projects.orderBy('views', 'desc').limit(3).get() const topProjectsSnap = await refList.projects.orderBy('views', 'desc').limit(100).get()
const topProjects = topProjectsSnap.docs.map((doc, index) => { const topProjects = topProjectsSnap.docs
.filter((doc) => doc.data()?.songPublishedOnMusicLand !== false)
.slice(0, 3)
.map((doc, index) => {
const data = doc.data() || {} const data = doc.data() || {}
return { return {
rank: index + 1, rank: index + 1,
+6 -1
View File
@@ -9,7 +9,12 @@ const {
COIN_PACK_PRODUCTS, COIN_PACK_PRODUCTS,
} = require('./constants') } = require('./constants')
const { parseCoinsPerMonth, formatCoinPack } = require('./shared') 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) => { const formatPlan = (price, priceId) => {
if (!price || typeof price !== 'object') { if (!price || typeof price !== 'object') {
+6 -1
View File
@@ -18,7 +18,12 @@ const {
const { refsList, formatCoinPack, getSubscriptionMetaFromPrice } = require('./shared') const { refsList, formatCoinPack, getSubscriptionMetaFromPrice } = require('./shared')
const CHECKOUT_UI_MODES = new Set(['hosted', 'embedded']) 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) => { const sanitizePriceId = (value) => {
if (typeof value !== 'string') { if (typeof value !== 'string') {
+6 -1
View File
@@ -5,7 +5,12 @@ const { STRIPE_SECRET_KEY } = require('../../config/secrets')
const { REGION } = require('./config') const { REGION } = require('./config')
const { CANCELABLE_SUBSCRIPTION_STATUSES, ACTIVE_SUBSCRIPTION_STATUSES } = require('./constants') const { CANCELABLE_SUBSCRIPTION_STATUSES, ACTIVE_SUBSCRIPTION_STATUSES } = require('./constants')
const { refsList, formatSubscriptionForClient, resolveUserContext } = require('./shared') 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) => { const cancelActiveSubscription = onCall(STRIPE_FUNCTION_OPTIONS, async (request) => {
try { try {
-294
View File
@@ -1,294 +0,0 @@
const { onCall, HttpsError } = require('firebase-functions/v2/https')
const { defineSecret } = require('firebase-functions/params')
const logger = require('firebase-functions/logger')
const functions = require('firebase-functions')
const admin = require('firebase-admin')
const { FieldValue } = require('firebase-admin/firestore')
const axios = require('axios')
const fs = require('node:fs')
const fsp = require('node:fs/promises')
const os = require('node:os')
const path = require('node:path')
const { google } = require('googleapis')
// ---- Secrets déclarés (gen2 + Secret Manager)
const S_YT_CLIENT_ID = defineSecret('YOUTUBE_CLIENT_ID')
const S_YT_CLIENT_SECRET = defineSecret('YOUTUBE_CLIENT_SECRET')
const S_YT_REFRESH_TOKEN = defineSecret('YOUTUBE_REFRESH_TOKEN')
const S_YT_REDIRECT_URI = defineSecret('YOUTUBE_REDIRECT_URI')
const S_YT_PRIVACY_STATUS = defineSecret('YOUTUBE_PRIVACY_STATUS')
const S_YT_CATEGORY_ID = defineSecret('YOUTUBE_CATEGORY_ID')
// Firestore
const firestore = admin.firestore()
const projectsRef = firestore.collection('projects')
const YOUTUBE_IN_PROGRESS_STATUSES = ['PUBLISHING', 'UPLOADING', 'PROCESSING', 'QUEUED']
// Lecture des secrets (recommandé en v2)
const getSecretsYoutubeConfig = () =>
Object.fromEntries(
Object.entries({
client_id: S_YT_CLIENT_ID.value(),
client_secret: S_YT_CLIENT_SECRET.value(),
refresh_token: S_YT_REFRESH_TOKEN.value(),
redirect_uri: S_YT_REDIRECT_URI.value(),
privacy_status: S_YT_PRIVACY_STATUS.value(),
category_id: S_YT_CATEGORY_ID.value(),
}).filter(([, value]) => value !== undefined && value !== '')
)
// Compat facultative v1 -> renverra {} en v2 (et on log un warn propre)
const getLegacyYoutubeConfig = () => {
if (typeof functions.config !== 'function') {
return {}
}
try {
return functions.config()?.youtube || {}
} catch (error) {
if (
typeof error?.message === 'string' &&
error.message.includes('functions.config() is no longer available')
) {
logger.warn(
'[publishPlaybackToYoutube] functions.config() indisponible, utilisation des secrets (Secret Manager)'
)
return {}
}
throw error
}
}
const ensureYoutubeConfig = () => {
// Fusionne (par prudence) lancienne config et les secrets actuels
const firebaseConfig = getLegacyYoutubeConfig()
const secretConfig = getSecretsYoutubeConfig()
const cfg = { ...firebaseConfig, ...secretConfig }
const requiredKeys = ['client_id', 'client_secret', 'refresh_token']
const missing = requiredKeys.filter((key) => !cfg[key])
if (missing.length) {
throw new HttpsError(
'failed-precondition',
`Configuration YouTube manquante: ${missing.join(', ')}`
)
}
return {
clientId: cfg.client_id,
clientSecret: cfg.client_secret,
refreshToken: cfg.refresh_token,
redirectUri: cfg.redirect_uri,
defaultPrivacyStatus: cfg.privacy_status,
defaultCategoryId: cfg.category_id,
}
}
const createYoutubeClient = ({ clientId, clientSecret, refreshToken, redirectUri }) => {
const oauth2Client = new google.auth.OAuth2(clientId, clientSecret, redirectUri)
oauth2Client.setCredentials({ refresh_token: refreshToken })
const youtube = google.youtube({
version: 'v3',
auth: oauth2Client,
})
return { youtube, oauth2Client }
}
const downloadFile = async (url, destinationPath) => {
if (!/^https?:\/\//i.test(url || '')) {
throw new HttpsError('invalid-argument', `URL non valide: ${url}`)
}
await fsp.mkdir(path.dirname(destinationPath), { recursive: true })
const response = await axios.get(url, { responseType: 'stream' })
await new Promise((resolve, reject) => {
const writer = fs.createWriteStream(destinationPath)
response.data.pipe(writer)
writer.on('finish', resolve)
writer.on('error', reject)
})
return destinationPath
}
const buildVideoMetadata = (project, defaults) => {
const baseTitle = project?.title || 'Création MusicLand'
const youtubeTitle = `${baseTitle} | MusicLand`
const description = `Vidéo générée avec MusicLand pour ${baseTitle}. Rejoins l'aventure sur l'app MusicLand !`
const tags = Array.isArray(project?.youtubeTags)
? project.youtubeTags.filter(Boolean).slice(0, 500)
: undefined
const snippet = {
title: youtubeTitle,
description,
categoryId: defaults.defaultCategoryId,
}
if (tags && tags.length) {
snippet.tags = tags
}
return {
snippet,
status: {
privacyStatus: defaults.defaultPrivacyStatus,
embeddable: true,
selfDeclaredMadeForKids: false,
},
}
}
exports.publishPlaybackToYoutube = onCall(
{
timeoutSeconds: 540,
memory: '1GiB',
cors: [
'http://localhost:8081',
'https://musicland-one.vercel.app/',
'https://musicland-d33f9.firebaseapp.com',
],
// Secrets requis pour lexécution (v2)
secrets: [
S_YT_CLIENT_ID,
S_YT_CLIENT_SECRET,
S_YT_REFRESH_TOKEN,
S_YT_REDIRECT_URI,
S_YT_PRIVACY_STATUS,
S_YT_CATEGORY_ID,
],
},
async ({ data = {}, auth }) => {
const uid = auth?.uid
if (!uid) {
throw new HttpsError('unauthenticated', 'Authentification requise')
}
const projectId = data?.projectId
if (!projectId || typeof projectId !== 'string') {
throw new HttpsError('invalid-argument', 'Paramètre projectId requis')
}
const projectSnap = await projectsRef.doc(projectId).get()
if (!projectSnap.exists) {
throw new HttpsError('not-found', 'Projet introuvable')
}
const project = projectSnap.data()
if (!project || project.userId !== uid) {
throw new HttpsError('permission-denied', "Vous n'avez pas les droits sur ce projet")
}
if (!project.playbackUrl) {
throw new HttpsError('failed-precondition', 'Aucun playback disponible pour la publication')
}
if (project.youtubeStatus && YOUTUBE_IN_PROGRESS_STATUSES.includes(project.youtubeStatus)) {
throw new HttpsError(
'failed-precondition',
'Une publication est déjà en cours pour ce projet'
)
}
let youtubeDefaults
let youtubeClient
try {
youtubeDefaults = ensureYoutubeConfig()
youtubeClient = createYoutubeClient(youtubeDefaults)
await youtubeClient.oauth2Client.getAccessToken()
} catch (error) {
logger.error('[publishPlaybackToYoutube] configuration invalide', {
error: error?.message,
})
throw new HttpsError('failed-precondition', 'Configuration YouTube invalide ou incomplète')
}
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'yt-upload-'))
const videoPath = path.join(tmpDir, 'playback.mp4')
try {
await projectsRef.doc(projectId).set(
{
youtubeStatus: 'PUBLISHING',
youtubePublished: false,
youtubeError: null,
updatedAt: FieldValue.serverTimestamp(),
},
{ merge: true }
)
await downloadFile(project.playbackUrl, videoPath)
const metadata = buildVideoMetadata(project, youtubeDefaults)
const uploadResponse = await youtubeClient.youtube.videos.insert({
part: ['snippet', 'status'].join(','),
requestBody: metadata,
media: {
body: fs.createReadStream(videoPath),
},
})
const videoId = uploadResponse?.data?.id
if (!videoId) {
throw new Error('ID de vidéo introuvable dans la réponse YouTube')
}
const youtubeLink = `https://www.youtube.com/watch?v=${videoId}`
await projectsRef.doc(projectId).set(
{
youtubeStatus: 'PUBLISHED',
youtubePublished: true,
youtubeUrl: youtubeLink,
youtubeVideoId: videoId,
youtubePublishedAt: FieldValue.serverTimestamp(),
youtubeError: null,
updatedAt: FieldValue.serverTimestamp(),
},
{ merge: true }
)
logger.info('[publishPlaybackToYoutube] publication réussie', {
projectId,
videoId,
})
return {
videoId,
youtubeUrl: youtubeLink,
}
} catch (error) {
logger.error('[publishPlaybackToYoutube] échec de publication', {
projectId,
error: error?.message,
})
const errorMessage =
error instanceof HttpsError
? error.message
: error?.message || 'Publication YouTube échouée'
await projectsRef.doc(projectId).set(
{
youtubeStatus: 'FAILED',
youtubePublished: false,
youtubeError: errorMessage,
updatedAt: FieldValue.serverTimestamp(),
},
{ merge: true }
)
if (error instanceof HttpsError) {
throw error
}
throw new HttpsError('internal', errorMessage)
} finally {
await fsp.rm(tmpDir, { recursive: true, force: true })
}
}
)
+3 -1
View File
@@ -250,6 +250,9 @@ export const videos = {
test: test:
Platform.OS === 'web' ? require('./video/testVideoWeb.mp4') : require('./video/testVideo.mp4'), Platform.OS === 'web' ? require('./video/testVideoWeb.mp4') : require('./video/testVideo.mp4'),
club: require('./video/club.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 = { export const img = {
@@ -267,7 +270,6 @@ export const cardsImg = {
writing: require('./icons/writing.png'), writing: require('./icons/writing.png'),
studio: require('./icons/studio.png'), studio: require('./icons/studio.png'),
video: require('./icons/video.png'), video: require('./icons/video.png'),
production: require('./icons/production.png'),
} }
export const subBadges = { export const subBadges = {
Binary file not shown.
Binary file not shown.
Binary file not shown.
+15 -8
View File
@@ -7,7 +7,7 @@ import { Fonts, Palette, gutters } from '../styles'
import GradientButton from './GradientButton' import GradientButton from './GradientButton'
import { LinearGradient } from './LinearGradient/LinearGradient' import { LinearGradient } from './LinearGradient/LinearGradient'
import Overlay from './Overlay' import Overlay from './Overlay'
const WebAlertModal = ({ title, description, options }) => { const WebAlertModal = ({ title, description, options, onDismiss }) => {
const [visible, setVisible] = useState(true) const [visible, setVisible] = useState(true)
const confirmOption = options?.find(({ style }) => style !== 'cancel') const confirmOption = options?.find(({ style }) => style !== 'cancel')
@@ -15,14 +15,18 @@ const WebAlertModal = ({ title, description, options }) => {
const hasSecondaryAction = Boolean(cancelOption) const hasSecondaryAction = Boolean(cancelOption)
const buttonContainerStyle = hasSecondaryAction ? styles.actionButton : styles.singleActionButton const buttonContainerStyle = hasSecondaryAction ? styles.actionButton : styles.singleActionButton
const onConfirm = () => { const dismiss = (callback) => {
setVisible(false) setVisible(false)
confirmOption?.onPress() setTimeout(onDismiss, 500)
callback?.()
}
const onConfirm = () => {
dismiss(confirmOption?.onPress)
} }
const onCancel = () => { const onCancel = () => {
setVisible(false) dismiss(cancelOption?.onPress)
cancelOption?.onPress()
} }
const renderDescription = () => { const renderDescription = () => {
@@ -80,8 +84,12 @@ const alertPolyfill = (title, description, options, extra) => {
const rootDiv = document.createElement('div') const rootDiv = document.createElement('div')
document.body.appendChild(rootDiv) document.body.appendChild(rootDiv)
const { createRoot } = require('react-dom/client')
const root = createRoot(rootDiv)
const closeModal = () => { const closeModal = () => {
document.body.removeChild(rootDiv) root.unmount()
rootDiv.remove()
} }
const WebAlertComponent = () => ( const WebAlertComponent = () => (
@@ -93,8 +101,7 @@ const alertPolyfill = (title, description, options, extra) => {
/> />
) )
// Render the React component into the div root.render(<WebAlertComponent />)
require('react-dom').render(<WebAlertComponent />, rootDiv)
} }
const customAlert = (title, description, options, extra) => { const customAlert = (title, description, options, extra) => {
+2 -18
View File
@@ -1,17 +1,8 @@
import { MaterialIcons } from '@expo/vector-icons' import { MaterialIcons } from '@expo/vector-icons'
import { Asset } from 'expo-asset'
import React, { useEffect, useRef, useState } from 'react' import React, { useEffect, useRef, useState } from 'react'
import { Pressable, StyleSheet, Text, View } from 'react-native' import { Pressable, StyleSheet, Text, View } from 'react-native'
const resolveModuleUri = async (source) => { import { resolveMediaUri } from '../utils/resolveMediaUri'
const asset = Asset.fromModule(source)
if (!asset.localUri && !asset.uri) {
await asset.downloadAsync()
}
return asset.localUri ?? asset.uri ?? null
}
const BackgroundVideo = ({ source, soundButtonStyle = null }) => { const BackgroundVideo = ({ source, soundButtonStyle = null }) => {
const videoRef = useRef(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 () => { const loadSource = async () => {
try { try {
const nextUri = await resolveModuleUri(source) const nextUri = await resolveMediaUri(source)
if (isMounted) { if (isMounted) {
setUri(nextUri) setUri(nextUri)
} }
@@ -2,7 +2,7 @@ import { BlurView } from 'expo-blur'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Animated, FlatList, Platform, StyleSheet, View, useWindowDimensions } from 'react-native' import { Animated, FlatList, Platform, StyleSheet, View, useWindowDimensions } from 'react-native'
import { responsiveHeight } from '../../actions/responsiveSizes' import { responsiveHeight } from '../../actions/responsiveSizes'
import { ai, cardsImg } from '../../assets' import { ai } from '../../assets'
import { gutters } from '../../styles' import { gutters } from '../../styles'
import { getCreationStageStates } from '../../utils/projectStages' import { getCreationStageStates } from '../../utils/projectStages'
import AnimatedPaginationDot from '../AnimatedPaginationDot/AnimatedPaginationDot' import AnimatedPaginationDot from '../AnimatedPaginationDot/AnimatedPaginationDot'
@@ -27,12 +27,6 @@ const STAGE_CARD_CONTENT = [
description: "Theo t'accompagne pour créer ton playback.", description: "Theo t'accompagne pour créer ton playback.",
image: ai.rightIcon, image: ai.rightIcon,
}, },
{
key: 'publisher',
title: 'Publication',
description: 'Ta vidéo est prête ? Direction YouTube !',
image: cardsImg.production,
},
] ]
const WEB_SCROLL_INACTIVE_DELTA = 0.05 const WEB_SCROLL_INACTIVE_DELTA = 0.05
@@ -8,7 +8,7 @@ import {
View, View,
useWindowDimensions, useWindowDimensions,
} from 'react-native' } from 'react-native'
import { ai, cardsImg } from '../../assets' import { ai } from '../../assets'
import { getCreationStageStates } from '../../utils/projectStages' import { getCreationStageStates } from '../../utils/projectStages'
import AnimatedPaginationDot from '../AnimatedPaginationDot/AnimatedPaginationDot' import AnimatedPaginationDot from '../AnimatedPaginationDot/AnimatedPaginationDot'
import PersonaCard from '../cards/PersonaCard/PersonaCard' import PersonaCard from '../cards/PersonaCard/PersonaCard'
@@ -33,12 +33,6 @@ const STAGE_CARD_CONTENT = [
description: "Theo t'accompagne pour créer ton playback.", description: "Theo t'accompagne pour créer ton playback.",
image: ai.rightIcon, image: ai.rightIcon,
}, },
{
key: 'publisher',
title: 'Publication',
description: 'Ta vidéo est prête ? Direction YouTube !',
image: cardsImg.production,
},
] ]
const WEB_SCROLL_INACTIVE_DELTA = 0.05 const WEB_SCROLL_INACTIVE_DELTA = 0.05
+12 -3
View File
@@ -11,7 +11,14 @@ import { Portal } from '@gorhom/portal'
// - onClose: () => void, called when user skips or when video ends // - onClose: () => void, called when user skips or when video ends
const CLOSE_THRESHOLD_SECONDS = 0.35 const CLOSE_THRESHOLD_SECONDS = 0.35
const FullscreenIntroVideo = ({ url, visible = true, onClose }) => { const FullscreenIntroVideo = ({
url,
visible = true,
onClose,
contentFit = 'contain',
allowSkip = true,
skipLabel = 'Passer la vidéo',
}) => {
const source = url ? (typeof url === 'string' ? { uri: url } : url) : videos.test const source = url ? (typeof url === 'string' ? { uri: url } : url) : videos.test
const hasClosedRef = useRef(false) const hasClosedRef = useRef(false)
@@ -103,7 +110,7 @@ const FullscreenIntroVideo = ({ url, visible = true, onClose }) => {
<VideoView <VideoView
player={player} player={player}
nativeControls={false} nativeControls={false}
contentFit="contain" contentFit={contentFit}
height="100%" height="100%"
width="100%" width="100%"
// style={{ // style={{
@@ -114,6 +121,7 @@ const FullscreenIntroVideo = ({ url, visible = true, onClose }) => {
// right: 24, // right: 24,
// }} // }}
/> />
{allowSkip ? (
<Pressable <Pressable
onPress={handleClose} onPress={handleClose}
style={{ style={{
@@ -128,8 +136,9 @@ const FullscreenIntroVideo = ({ url, visible = true, onClose }) => {
borderColor: '#FFFFFF55', borderColor: '#FFFFFF55',
}} }}
> >
<Text style={{ color: '#FFF', fontSize: 14 }}>Passer la vidéo</Text> <Text style={{ color: '#FFF', fontSize: 14 }}>{skipLabel}</Text>
</Pressable> </Pressable>
) : null}
</View> </View>
</Portal> </Portal>
) )
+65 -24
View File
@@ -1,8 +1,9 @@
import { Portal } from '@gorhom/portal' import { Portal } from '@gorhom/portal'
import { Asset } from 'expo-asset'
import React, { useCallback, useEffect, useRef, useState } from 'react' import React, { useCallback, useEffect, useRef, useState } from 'react'
import { Pressable, Text, View } from 'react-native' import { Pressable, Text, View } from 'react-native'
import { resolveMediaUri } from '../utils/resolveMediaUri'
const CLOSE_THRESHOLD_SECONDS = 0.35 const CLOSE_THRESHOLD_SECONDS = 0.35
const CLOSE_POLL_INTERVAL_MS = 500 const CLOSE_POLL_INTERVAL_MS = 500
@@ -45,20 +46,32 @@ const closeTextStyle = {
fontSize: 14, fontSize: 14,
} }
const resolveModuleUri = async (module) => { const playButtonStyle = {
const asset = Asset.fromModule(module) backgroundColor: '#000000CC',
paddingVertical: 14,
if (!asset.localUri && !asset.uri) { paddingHorizontal: 20,
await asset.downloadAsync() borderRadius: 24,
} borderWidth: 1,
borderColor: '#FFFFFF88',
return asset.localUri ?? asset.uri ?? null cursor: 'pointer',
zIndex: 1,
} }
const FullscreenIntroVideo = ({ url, visible = true, onClose }) => { const FullscreenIntroVideo = ({
url,
visible = true,
onClose,
contentFit = 'cover',
allowSkip = true,
requireAudio = false,
offerSoundActivation = false,
skipLabel = 'Passer la vidéo',
playLabel = 'Lire la vidéo avec le son',
}) => {
const videoRef = useRef(null) const videoRef = useRef(null)
const [uri, setUri] = useState(null) const [uri, setUri] = useState(null)
const [muted, setMuted] = useState(false) const [muted, setMuted] = useState(false)
const [awaitingInteraction, setAwaitingInteraction] = useState(false)
const hasClosedRef = useRef(false) const hasClosedRef = useRef(false)
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
@@ -92,24 +105,26 @@ const FullscreenIntroVideo = ({ url, visible = true, onClose }) => {
const assignUri = (nextUri) => { const assignUri = (nextUri) => {
if (isMounted) { if (isMounted) {
setMuted(false) setMuted(false)
setAwaitingInteraction(false)
setUri(nextUri) setUri(nextUri)
} }
} }
if (typeof url === 'string') {
assignUri(url)
return () => {
isMounted = false
}
}
const load = async () => { const load = async () => {
try { try {
const nextUri = await resolveModuleUri(url) const nextUri = await resolveMediaUri(url)
if (!nextUri) {
if (isMounted) {
setUri(null)
handleClose()
}
return
}
assignUri(nextUri) assignUri(nextUri)
} catch { } catch {
if (isMounted) { if (isMounted) {
setUri(null) setUri(null)
handleClose()
} }
} }
} }
@@ -119,7 +134,7 @@ const FullscreenIntroVideo = ({ url, visible = true, onClose }) => {
return () => { return () => {
isMounted = false isMounted = false
} }
}, [url]) }, [handleClose, url])
useEffect(() => { useEffect(() => {
if (!visible || !uri) { if (!visible || !uri) {
@@ -142,8 +157,14 @@ const FullscreenIntroVideo = ({ url, visible = true, onClose }) => {
if (result?.catch) { if (result?.catch) {
result.catch((error) => { result.catch((error) => {
if (error?.name === 'NotAllowedError' && !muted) { if (error?.name === 'NotAllowedError' && requireAudio) {
setAwaitingInteraction(true)
} else if (error?.name === 'NotAllowedError' && !muted) {
video.muted = true
setMuted(true) setMuted(true)
setAwaitingInteraction(offerSoundActivation)
const mutedResult = video.play()
mutedResult?.catch?.(() => {})
} }
}) })
} }
@@ -160,7 +181,20 @@ const FullscreenIntroVideo = ({ url, visible = true, onClose }) => {
const video = videoRef.current const video = videoRef.current
video?.pause() video?.pause()
} }
}, [evaluateShouldClose, muted, uri, visible]) }, [evaluateShouldClose, muted, offerSoundActivation, 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(() => { useEffect(() => {
const video = videoRef.current const video = videoRef.current
@@ -187,7 +221,7 @@ const FullscreenIntroVideo = ({ url, visible = true, onClose }) => {
<video <video
ref={videoRef} ref={videoRef}
src={uri} src={uri}
style={videoStyle} style={{ ...videoStyle, objectFit: contentFit }}
playsInline playsInline
autoPlay autoPlay
loop={false} loop={false}
@@ -199,9 +233,16 @@ const FullscreenIntroVideo = ({ url, visible = true, onClose }) => {
onError={handleClose} onError={handleClose}
/> />
) : null} ) : null}
<Pressable onPress={handleClose} style={closeButtonStyle}> {awaitingInteraction ? (
<Text style={closeTextStyle}>Passer la vidéo</Text> <Pressable onPress={handleManualPlay} style={playButtonStyle}>
<Text style={closeTextStyle}>{playLabel}</Text>
</Pressable> </Pressable>
) : null}
{allowSkip ? (
<Pressable onPress={handleClose} style={closeButtonStyle}>
<Text style={closeTextStyle}>{skipLabel}</Text>
</Pressable>
) : null}
</View> </View>
</Portal> </Portal>
) )
+1 -5
View File
@@ -128,7 +128,6 @@ const alphaWhite = (alpha) => `rgba(255,255,255,${alpha})`
export default function KaraokeLyrics({ export default function KaraokeLyrics({
alignedWords = [], alignedWords = [],
currentTimeS = 0, currentTimeS = 0,
timeOffsetS = 0,
showContext = true, showContext = true,
removeTags = true, removeTags = true,
mode = 'focus', mode = 'focus',
@@ -148,10 +147,7 @@ export default function KaraokeLyrics({
) )
const activeLines = mode === 'teleprompter' ? teleprompterData : lines const activeLines = mode === 'teleprompter' ? teleprompterData : lines
const effectiveTimeS = useMemo( const effectiveTimeS = Math.max(0, Number(currentTimeS || 0))
() => Math.max(0, Number(currentTimeS || 0) + Number(timeOffsetS || 0)),
[currentTimeS, timeOffsetS]
)
const currentLineIdx = useMemo(() => { const currentLineIdx = useMemo(() => {
return findFocusedLineIndex(activeLines, effectiveTimeS) return findFocusedLineIndex(activeLines, effectiveTimeS)
+19 -1
View File
@@ -22,6 +22,8 @@ export default ({
alignItems: 'flex-start', alignItems: 'flex-start',
marginBottom: gutters / 2, marginBottom: gutters / 2,
paddingTop: 10, paddingTop: 10,
position: 'relative',
zIndex: 10,
...containerStyle, ...containerStyle,
}, },
]} ]}
@@ -29,7 +31,23 @@ export default ({
<View style={{ flex: 1 }}> <View style={{ flex: 1 }}>
{!hideBackButton && ( {!hideBackButton && (
<Pressable <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 }} hitSlop={{ top: 16, right: 16, bottom: 16, left: 16 }}
> >
<Image <Image
+6 -2
View File
@@ -19,6 +19,7 @@ const Overlay = ({
contentContainerStyle = {}, contentContainerStyle = {},
hasPortal = true, hasPortal = true,
keepMounted = false,
}) => { }) => {
const { isNative } = useLayoutType() const { isNative } = useLayoutType()
@@ -27,15 +28,17 @@ const Overlay = ({
const shouldUseBlur = resolvedBlurIntensity > 0 const shouldUseBlur = resolvedBlurIntensity > 0
const BackgroundView = shouldUseBlur ? BlurView : View const BackgroundView = shouldUseBlur ? BlurView : View
const backgroundProps = shouldUseBlur ? { intensity: resolvedBlurIntensity, tint: 'dark' } : {} const backgroundProps = shouldUseBlur ? { intensity: resolvedBlurIntensity, tint: 'dark' } : {}
const shouldRender = isVisible || keepMounted
const isKeptHidden = keepMounted && !isVisible
return ( return (
<ContentContainerView> <ContentContainerView>
<AnimatePresence> <AnimatePresence>
{isVisible ? ( {shouldRender ? (
<Motion.View <Motion.View
key="overlay" key="overlay"
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: isVisible ? 1 : 0 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
transition={{ transition={{
type: 'tween', type: 'tween',
@@ -47,6 +50,7 @@ const Overlay = ({
backgroundColor: Palette.ultraLightBlack, backgroundColor: Palette.ultraLightBlack,
flex: 1, flex: 1,
...Style.containerCenter, ...Style.containerCenter,
pointerEvents: isKeptHidden ? 'none' : 'auto',
}} }}
> >
<BackgroundView <BackgroundView
+13 -8
View File
@@ -10,10 +10,10 @@ import { Platform } from 'react-native'
const functionsInstances = {} const functionsInstances = {}
const emulatorConfigured = {} const emulatorConfigured = {}
const emulatorHost = Platform.OS === 'web' ? 'localhost' : '192.168.1.60' 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 configureFunctionsEmulator = (instance, regionKey = 'us-central1') => {
const shouldUseEmulator = USE_FUNCTIONS_EMULATOR && instance?.useEmulator const shouldUseEmulator = false
if (!shouldUseEmulator || emulatorConfigured[regionKey]) { if (!shouldUseEmulator || emulatorConfigured[regionKey]) {
return return
@@ -56,15 +56,20 @@ if (!firebase?.apps?.filter(({ name_ }) => name_ === '[DEFAULT]').length) {
console.log('Firebase init') console.log('Firebase init')
} }
// Firestore settings for React Native/iOS: avoid streaming transport issues
const firestore = firebase.firestore() const firestore = firebase.firestore()
try { try {
// Prefer auto-detect; disable fetch streams for RN iOS stability const firestoreSettings = {
firebase.firestore().settings({
experimentalAutoDetectLongPolling: true,
useFetchStreams: false,
ignoreUndefinedProperties: true, 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) { } catch (e) {
// Ignore if settings were already set elsewhere // Ignore if settings were already set elsewhere
} }
+1
View File
@@ -0,0 +1 @@
export const REGISTRATION_TERMS_VERSION = 'musicland-2023-10-22'
+11 -1
View File
@@ -33,7 +33,8 @@ export function uploadFileToFirebase({
shouldCompress, shouldCompress,
fileType, fileType,
hasBlob: Boolean(providedBlob), hasBlob: Boolean(providedBlob),
uri, uriScheme: typeof uri === 'string' ? uri.split(':')[0] : null,
uriLength: typeof uri === 'string' ? uri.length : 0,
}) })
let workingURI = uri let workingURI = uri
let uploadBlob = providedBlob let uploadBlob = providedBlob
@@ -170,6 +171,7 @@ export function uploadFileToFirebase({
reject(error) reject(error)
}, },
async () => { async () => {
try {
const url = await firebase.storage().ref(path).getDownloadURL() const url = await firebase.storage().ref(path).getDownloadURL()
console.log('[uploadToFirebase] upload success (finalize)', { resultURI: url }) console.log('[uploadToFirebase] upload success (finalize)', { resultURI: url })
@@ -177,6 +179,14 @@ export function uploadFileToFirebase({
uploadBlob.close() uploadBlob.close()
} }
resolve({ resultURI: url }) resolve({ resultURI: url })
} catch (error) {
console.log('[uploadToFirebase] download URL error', {
message: error?.message,
code: error?.code,
name: error?.name,
})
reject(error)
}
} }
) )
} catch (e) { } catch (e) {
+5 -1
View File
@@ -47,10 +47,14 @@ const usePlaylistMusicSearch = () => {
} }
}, [musics]) }, [musics])
const visibleMusics = (hydratedMusics ?? musics)?.filter(
(project) => project?.songPublishedOnMusicLand !== false
)
return { return {
search, search,
setSearch, setSearch,
musics: hydratedMusics ?? musics, musics: visibleMusics,
loading, loading,
hasSearch: normalizedSearch.length > 0, hasSearch: normalizedSearch.length > 0,
} }
+34
View File
@@ -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
+9 -2
View File
@@ -96,14 +96,21 @@ const useSearch = () => {
} }
}, [playbacks]) }, [playbacks])
const visibleMusics = (hydratedMusics ?? musics)?.filter(
(project) => project?.songPublishedOnMusicLand !== false
)
const visiblePlaybacks = (hydratedPlaybacks ?? playbacks)?.filter(
(project) => project?.playbackPublishedOnMusicLand !== false
)
return { return {
search, search,
setSearch, setSearch,
selected, selected,
setSelected, setSelected,
users, users,
musics: hydratedMusics ?? musics, musics: visibleMusics,
playbacks: hydratedPlaybacks ?? playbacks, playbacks: visiblePlaybacks,
loading: userLoading || musicLoading || playbackLoading, loading: userLoading || musicLoading || playbackLoading,
} }
} }
+11 -2
View File
@@ -69,13 +69,14 @@ const useSharedAudioPlayer = (source, options = null) => {
durationMs: 0, durationMs: 0,
}) })
useEffect(() => { // Keep the imperative player getters on the exact snapshot that triggered
// this render. Consumers can therefore use the shared player as their only
// playback clock without maintaining a second timer.
stateRef.current = { stateRef.current = {
isPlaying, isPlaying,
positionMs, positionMs,
durationMs, durationMs,
} }
}, [isPlaying, positionMs, durationMs])
const controlsRef = useRef({ const controlsRef = useRef({
play, play,
@@ -135,6 +136,14 @@ const useSharedAudioPlayer = (source, options = null) => {
enumerable: true, enumerable: true,
get: () => stateRef.current.positionMs / 1000, get: () => stateRef.current.positionMs / 1000,
}, },
positionMs: {
enumerable: true,
get: () => stateRef.current.positionMs,
},
durationMs: {
enumerable: true,
get: () => stateRef.current.durationMs,
},
}) })
player.load = async ({ startPositionMs = 0 } = {}) => { player.load = async ({ startPositionMs = 0 } = {}) => {
+13 -5
View File
@@ -11,6 +11,7 @@ import { useGlobal } from 'reactn'
import { handleFirebaseError } from '../actions/signupActions' import { handleFirebaseError } from '../actions/signupActions'
import firebase, { usersRef } from '../config/firebase' import firebase, { usersRef } from '../config/firebase'
import { GOOGLE_ANDROID_CLIENT_ID, GOOGLE_IOS_CLIENT_ID, GOOGLE_WEB_CLIENT_ID } from '../data/keys' import { GOOGLE_ANDROID_CLIENT_ID, GOOGLE_IOS_CLIENT_ID, GOOGLE_WEB_CLIENT_ID } from '../data/keys'
import { REGISTRATION_TERMS_VERSION } from '../data/legal'
WebBrowser.maybeCompleteAuthSession() WebBrowser.maybeCompleteAuthSession()
@@ -131,6 +132,11 @@ const ensureUserDocument = async (user, overrides = {}, isNewUser = false) => {
payload.preferredLanguage = overridePreferredLanguage payload.preferredLanguage = overridePreferredLanguage
} }
if (isNewUser && overrides?.termsAccepted === true) {
payload.termsAcceptedAt = firebase.firestore.FieldValue.serverTimestamp()
payload.termsVersion = REGISTRATION_TERMS_VERSION
}
await docRef.set(payload, { merge: true }) await docRef.set(payload, { merge: true })
return { return {
isNewUser, isNewUser,
@@ -145,7 +151,7 @@ const ensureUserDocument = async (user, overrides = {}, isNewUser = false) => {
} }
} }
const useSocialAuth = ({ onSuccess, preferredLanguage = null } = {}) => { const useSocialAuth = ({ onSuccess, preferredLanguage = null, termsAccepted = false } = {}) => {
const [, setTooltip] = useGlobal('_tooltip') const [, setTooltip] = useGlobal('_tooltip')
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [isAppleSignInAvailable, setIsAppleSignInAvailable] = useState(false) const [isAppleSignInAvailable, setIsAppleSignInAvailable] = useState(false)
@@ -242,7 +248,7 @@ const useSocialAuth = ({ onSuccess, preferredLanguage = null } = {}) => {
const userCredential = await firebase.auth().signInWithCredential(credential) const userCredential = await firebase.auth().signInWithCredential(credential)
const authResult = await ensureUserDocument( const authResult = await ensureUserDocument(
userCredential?.user, userCredential?.user,
{ preferredLanguage }, { preferredLanguage, termsAccepted },
userCredential?.additionalUserInfo?.isNewUser === true userCredential?.additionalUserInfo?.isNewUser === true
) )
if (typeof onSuccess === 'function') { if (typeof onSuccess === 'function') {
@@ -260,7 +266,7 @@ const useSocialAuth = ({ onSuccess, preferredLanguage = null } = {}) => {
setIsLoading(false) setIsLoading(false)
} }
}, },
[onSuccess, preferredLanguage, redirectUri, request, setTooltip] [onSuccess, preferredLanguage, redirectUri, request, setTooltip, termsAccepted]
) )
useEffect(() => { useEffect(() => {
@@ -323,7 +329,7 @@ const useSocialAuth = ({ onSuccess, preferredLanguage = null } = {}) => {
const userCredential = await firebase.auth().signInWithPopup(provider) const userCredential = await firebase.auth().signInWithPopup(provider)
const authResult = await ensureUserDocument( const authResult = await ensureUserDocument(
userCredential?.user, userCredential?.user,
{ preferredLanguage }, { preferredLanguage, termsAccepted },
userCredential?.additionalUserInfo?.isNewUser === true userCredential?.additionalUserInfo?.isNewUser === true
) )
if (typeof onSuccess === 'function') { if (typeof onSuccess === 'function') {
@@ -380,6 +386,7 @@ const useSocialAuth = ({ onSuccess, preferredLanguage = null } = {}) => {
preferredLanguage, preferredLanguage,
promptAsync, promptAsync,
setTooltip, setTooltip,
termsAccepted,
]) ])
const signInWithApple = useCallback(async () => { const signInWithApple = useCallback(async () => {
@@ -431,6 +438,7 @@ const useSocialAuth = ({ onSuccess, preferredLanguage = null } = {}) => {
firstName, firstName,
lastName, lastName,
preferredLanguage, preferredLanguage,
termsAccepted,
displayName: displayName:
userCredential?.user?.displayName || userCredential?.user?.displayName ||
[firstName, lastName].filter(Boolean).join(' ') || [firstName, lastName].filter(Boolean).join(' ') ||
@@ -456,7 +464,7 @@ const useSocialAuth = ({ onSuccess, preferredLanguage = null } = {}) => {
setTooltip({ text: message, type: 'error' }) setTooltip({ text: message, type: 'error' })
setIsLoading(false) setIsLoading(false)
} }
}, [isAppleSignInAvailable, isLoading, onSuccess, preferredLanguage, setTooltip]) }, [isAppleSignInAvailable, isLoading, onSuccess, preferredLanguage, setTooltip, termsAccepted])
return { return {
signInWithGoogle, signInWithGoogle,
+10 -5
View File
@@ -21,11 +21,11 @@ import Playback from '../screens/Playback/Playback'
import PlaybackAi from '../screens/Playback/PlaybackAi' import PlaybackAi from '../screens/Playback/PlaybackAi'
import PlaybackAiStatus from '../screens/Playback/PlaybackAiStatus' import PlaybackAiStatus from '../screens/Playback/PlaybackAiStatus'
import PlaybackGuide from '../screens/Playback/PlaybackGuide' import PlaybackGuide from '../screens/Playback/PlaybackGuide'
import PlaybackImageRightsConsent from '../screens/Playback/PlaybackImageRightsConsent'
import PlaybackOnboarding from '../screens/Playback/PlaybackOnboarding' import PlaybackOnboarding from '../screens/Playback/PlaybackOnboarding'
import RecordPlayback from '../screens/Playback/RecordPlayback' import RecordPlayback from '../screens/Playback/RecordPlayback'
import RecordedPlayback from '../screens/Playback/RecordedPlayback' import RecordedPlayback from '../screens/Playback/RecordedPlayback'
import VideoFinalize from '../screens/Playback/VideoFinalize' import VideoFinalize from '../screens/Playback/VideoFinalize'
import PublishYoutube from '../screens/Publishing/PublishYoutube'
import PrivacyPolicy from '../screens/PrivacyPolicy' import PrivacyPolicy from '../screens/PrivacyPolicy'
import Subscriptions from '../screens/Subscriptions' import Subscriptions from '../screens/Subscriptions'
import DownloadPrices from '../screens/Production/DownloadPrices' import DownloadPrices from '../screens/Production/DownloadPrices'
@@ -48,6 +48,7 @@ import Profile from '../screens/Profile/Profile'
import Reels from '../screens/Profile/Reels' import Reels from '../screens/Profile/Reels'
import Settings from '../screens/Profile/Settings' import Settings from '../screens/Profile/Settings'
import Register from '../screens/Register' import Register from '../screens/Register'
import RegistrationTerms from '../screens/RegistrationTerms'
import Splash from '../screens/Splash' import Splash from '../screens/Splash'
import Compose from '../screens/Studio/Compose' import Compose from '../screens/Studio/Compose'
import ComposeSong from '../screens/Studio/ComposeSong' import ComposeSong from '../screens/Studio/ComposeSong'
@@ -233,6 +234,10 @@ const baseScreens = [
name: Routes.Playback, name: Routes.Playback,
component: Playback, component: Playback,
}, },
{
name: Routes.PlaybackImageRightsConsent,
component: PlaybackImageRightsConsent,
},
{ {
name: Routes.PlaybackGuide, name: Routes.PlaybackGuide,
component: PlaybackGuide, component: PlaybackGuide,
@@ -265,10 +270,6 @@ const baseScreens = [
name: Routes.VideoFinalize, name: Routes.VideoFinalize,
component: VideoFinalize, component: VideoFinalize,
}, },
{
name: Routes.PublishYoutube,
component: PublishYoutube,
},
{ {
name: Routes.AllMyPlaylist, name: Routes.AllMyPlaylist,
component: AllMyPlaylist, component: AllMyPlaylist,
@@ -342,6 +343,10 @@ const baseScreens = [
name: Routes.Register, name: Routes.Register,
component: Register, component: Register,
}, },
{
name: Routes.RegistrationTerms,
component: RegistrationTerms,
},
{ {
name: Routes.CreatePassword, name: Routes.CreatePassword,
component: CreatePassword, component: CreatePassword,
+2 -1
View File
@@ -5,6 +5,7 @@ export const Routes = {
ForgotPassword: 'ForgotPassword', ForgotPassword: 'ForgotPassword',
ResetPassword: 'ResetPassword', ResetPassword: 'ResetPassword',
Register: 'Register', Register: 'Register',
RegistrationTerms: 'RegistrationTerms',
CreatePassword: 'CreatePassword', CreatePassword: 'CreatePassword',
CreatePseudo: 'CreatePseudo', CreatePseudo: 'CreatePseudo',
RegistrationChoice: 'RegistrationChoice', RegistrationChoice: 'RegistrationChoice',
@@ -57,6 +58,7 @@ export const Routes = {
PlaybackOnboarding: 'PlaybackOnboarding', PlaybackOnboarding: 'PlaybackOnboarding',
Playback: 'Playback', Playback: 'Playback',
PlaybackImageRightsConsent: 'PlaybackImageRightsConsent',
PlaybackGuide: 'PlaybackGuide', PlaybackGuide: 'PlaybackGuide',
PlaybackAi: 'PlaybackAi', PlaybackAi: 'PlaybackAi',
PlaybackAiStatus: 'PlaybackAiStatus', PlaybackAiStatus: 'PlaybackAiStatus',
@@ -65,7 +67,6 @@ export const Routes = {
ChooseDecor: 'ChooseDecor', ChooseDecor: 'ChooseDecor',
CreatingDecor: 'CreatingDecor', CreatingDecor: 'CreatingDecor',
VideoFinalize: 'VideoFinalize', VideoFinalize: 'VideoFinalize',
PublishYoutube: 'PublishYoutube',
Create: 'Create', Create: 'Create',
HitParade: 'HitParade', HitParade: 'HitParade',
+2 -2
View File
@@ -35,6 +35,7 @@ const HIDDEN_ROUTE_NAMES = new Set([
Routes.PlaybackExample, Routes.PlaybackExample,
Routes.PlaybackOnboarding, Routes.PlaybackOnboarding,
Routes.Playback, Routes.Playback,
Routes.PlaybackImageRightsConsent,
Routes.PlaybackGuide, Routes.PlaybackGuide,
Routes.PlaybackAi, Routes.PlaybackAi,
Routes.PlaybackAiStatus, Routes.PlaybackAiStatus,
@@ -44,7 +45,6 @@ const HIDDEN_ROUTE_NAMES = new Set([
Routes.ChooseDecor, Routes.ChooseDecor,
Routes.CreatingDecor, Routes.CreatingDecor,
Routes.VideoFinalize, Routes.VideoFinalize,
Routes.PublishYoutube,
Routes.FlowSelection, Routes.FlowSelection,
Routes.ChooseCoverType, Routes.ChooseCoverType,
Routes.Settings, Routes.Settings,
@@ -374,7 +374,7 @@ const PlayerProvider = ({ children }) => {
return currentTrack.source return currentTrack.source
}, [currentTrack?.source]) }, [currentTrack?.source])
const player = useAudioPlayer(source || null, 200) const player = useAudioPlayer(source || null, 100)
const status = useAudioPlayerStatus(player) const status = useAudioPlayerStatus(player)
const shouldHideOnRoute = activeRouteName ? HIDDEN_ROUTE_NAMES.has(activeRouteName) : false const shouldHideOnRoute = activeRouteName ? HIDDEN_ROUTE_NAMES.has(activeRouteName) : false
const isStatusLoaded = useMemo(() => { const isStatusLoaded = useMemo(() => {
+221 -15
View File
@@ -1,10 +1,11 @@
import React from 'react' import React from 'react'
import { Linking, Platform, StyleSheet, View } from 'react-native' import { Linking, Platform, StyleSheet, Text, View } from 'react-native'
import { EmbeddedCheckout, EmbeddedCheckoutProvider } from '@stripe/react-stripe-js' import { EmbeddedCheckout, EmbeddedCheckoutProvider } from '@stripe/react-stripe-js'
import { loadStripe } from '@stripe/stripe-js' import { loadStripe } from '@stripe/stripe-js'
import * as WebBrowser from 'expo-web-browser' import * as WebBrowser from 'expo-web-browser'
import Overlay from '../components/Overlay' import Overlay from '../components/Overlay'
import Button from '../components/Button' import Button from '../components/Button'
import ActivityLoader from '../components/ActivityLoader'
import { Palette } from '../styles' import { Palette } from '../styles'
import { mainBorderRadius } from '../styles/Style' import { mainBorderRadius } from '../styles/Style'
import { STRIPE_PUBLISHABLE_KEY_LIVE, STRIPE_PUBLISHABLE_KEY_TEST } from '../data/keys' import { STRIPE_PUBLISHABLE_KEY_LIVE, STRIPE_PUBLISHABLE_KEY_TEST } from '../data/keys'
@@ -14,6 +15,8 @@ import { useUserData } from './UserDataProvider'
import { formatDate, toDate } from '../utils/dateFormatting' import { formatDate, toDate } from '../utils/dateFormatting'
const FUNCTIONS_REGION = 'europe-west1' const FUNCTIONS_REGION = 'europe-west1'
const EMBEDDED_CHECKOUT_BOOTSTRAP_HEIGHT = 620
const EMBEDDED_CHECKOUT_TIMEOUT_MS = 6000
const STRIPE_SUCCESS_URL = const STRIPE_SUCCESS_URL =
'https://dashboard.stripe.com/test/billing/starter-guide/checkout-success' 'https://dashboard.stripe.com/test/billing/starter-guide/checkout-success'
const STRIPE_CANCEL_URL = STRIPE_SUCCESS_URL const STRIPE_CANCEL_URL = STRIPE_SUCCESS_URL
@@ -85,6 +88,11 @@ const StripeProvider = ({ children }) => {
const canUseEmbeddedCheckout = isWeb && Boolean(stripePromise) const canUseEmbeddedCheckout = isWeb && Boolean(stripePromise)
const [clientSecret, setClientSecret] = React.useState(null) const [clientSecret, setClientSecret] = React.useState(null)
const [isEmbeddedCheckoutVisible, setIsEmbeddedCheckoutVisible] = React.useState(false)
const [embeddedCheckoutAttempt, setEmbeddedCheckoutAttempt] = React.useState(0)
const [embeddedCheckoutInstanceKey, setEmbeddedCheckoutInstanceKey] = React.useState(0)
const [embeddedCheckoutPhase, setEmbeddedCheckoutPhase] = React.useState('idle')
const embeddedCheckoutSessionKeyRef = React.useRef(null)
const [subscriptions, setSubscriptions] = React.useState({ const [subscriptions, setSubscriptions] = React.useState({
monthly: [], monthly: [],
annual: [], annual: [],
@@ -103,9 +111,32 @@ const StripeProvider = ({ children }) => {
null null
const closeEmbeddedCheckout = React.useCallback(() => { const closeEmbeddedCheckout = React.useCallback(() => {
setIsEmbeddedCheckoutVisible(false)
}, [])
const resetEmbeddedCheckout = React.useCallback(() => {
embeddedCheckoutSessionKeyRef.current = null
setIsEmbeddedCheckoutVisible(false)
setEmbeddedCheckoutAttempt(0)
setEmbeddedCheckoutPhase('idle')
setClientSecret(null) setClientSecret(null)
}, []) }, [])
const retryEmbeddedCheckout = React.useCallback(() => {
setEmbeddedCheckoutPhase('loading')
setEmbeddedCheckoutAttempt((currentAttempt) => currentAttempt + 1)
setEmbeddedCheckoutInstanceKey((currentKey) => currentKey + 1)
}, [])
const openEmbeddedCheckout = React.useCallback((nextClientSecret) => {
embeddedCheckoutSessionKeyRef.current = null
setEmbeddedCheckoutAttempt(0)
setEmbeddedCheckoutPhase('loading')
setEmbeddedCheckoutInstanceKey((currentKey) => currentKey + 1)
setClientSecret(nextClientSecret)
setIsEmbeddedCheckoutVisible(true)
}, [])
const redirectToCheckout = React.useCallback(async (checkoutUrl = {}) => { const redirectToCheckout = React.useCallback(async (checkoutUrl = {}) => {
if (!checkoutUrl) { if (!checkoutUrl) {
throw new Error('Session Stripe introuvable.') throw new Error('Session Stripe introuvable.')
@@ -144,6 +175,20 @@ const StripeProvider = ({ children }) => {
} }
const wantsEmbeddedCheckout = useEmbeddedFlow && canUseEmbeddedCheckout const wantsEmbeddedCheckout = useEmbeddedFlow && canUseEmbeddedCheckout
const embeddedCheckoutSessionKey = wantsEmbeddedCheckout
? JSON.stringify({ callableName, payload })
: null
if (
wantsEmbeddedCheckout &&
clientSecret &&
embeddedCheckoutPhase !== 'failed' &&
embeddedCheckoutSessionKeyRef.current === embeddedCheckoutSessionKey
) {
setIsEmbeddedCheckoutVisible(true)
return
}
const safariWindow = wantsEmbeddedCheckout ? null : openSafariCheckoutWindow() const safariWindow = wantsEmbeddedCheckout ? null : openSafariCheckoutWindow()
try { try {
const callable = getFunctionsClient(FUNCTIONS_REGION).httpsCallable(callableName) const callable = getFunctionsClient(FUNCTIONS_REGION).httpsCallable(callableName)
@@ -155,7 +200,12 @@ const StripeProvider = ({ children }) => {
if (!clientSecret) { if (!clientSecret) {
throw new Error('Session Stripe introuvable.') throw new Error('Session Stripe introuvable.')
} }
embeddedCheckoutSessionKeyRef.current = embeddedCheckoutSessionKey
setEmbeddedCheckoutAttempt(0)
setEmbeddedCheckoutPhase('loading')
setEmbeddedCheckoutInstanceKey((currentKey) => currentKey + 1)
setClientSecret(clientSecret) setClientSecret(clientSecret)
setIsEmbeddedCheckoutVisible(true)
return return
} }
@@ -175,7 +225,12 @@ const StripeProvider = ({ children }) => {
) )
} }
}, },
[canUseEmbeddedCheckout, redirectToCheckout, setClientSecret] [
canUseEmbeddedCheckout,
clientSecret,
embeddedCheckoutPhase,
redirectToCheckout,
]
) )
const createSubscriptionCheckout = React.useCallback( const createSubscriptionCheckout = React.useCallback(
@@ -334,6 +389,96 @@ const StripeProvider = ({ children }) => {
} }
}, [stripeCustomerId, localSubscriptionId]) }, [stripeCustomerId, localSubscriptionId])
const handleEmbeddedCheckoutComplete = React.useCallback(() => {
resetEmbeddedCheckout()
fetchActiveSubscription()
}, [fetchActiveSubscription, resetEmbeddedCheckout])
const embeddedCheckoutOptions = React.useMemo(
() => ({
clientSecret,
onComplete: handleEmbeddedCheckoutComplete,
}),
[clientSecret, handleEmbeddedCheckoutComplete]
)
React.useEffect(() => {
if (
!isWeb ||
!clientSecret ||
embeddedCheckoutPhase !== 'loading' ||
typeof document === 'undefined' ||
typeof MutationObserver !== 'function'
) {
return undefined
}
let checkoutFrameObserver = null
let documentObserver = null
let observedFrame = null
let isReady = false
const markCheckoutReady = () => {
if (isReady) return
isReady = true
setEmbeddedCheckoutPhase('ready')
}
const inspectCheckoutFrame = () => {
const checkoutFrame = document.querySelector(
'#musicland-embedded-checkout iframe[title="Embedded checkout"]'
)
if (!checkoutFrame) return
const frameHeight = Number.parseFloat(checkoutFrame.style.height)
if (
Number.isFinite(frameHeight) &&
Math.abs(frameHeight - EMBEDDED_CHECKOUT_BOOTSTRAP_HEIGHT) > 1
) {
markCheckoutReady()
return
}
if (checkoutFrame === observedFrame) return
checkoutFrameObserver?.disconnect()
observedFrame = checkoutFrame
checkoutFrameObserver = new MutationObserver(inspectCheckoutFrame)
checkoutFrameObserver.observe(checkoutFrame, {
attributes: true,
attributeFilter: ['style'],
})
}
documentObserver = new MutationObserver(inspectCheckoutFrame)
documentObserver.observe(document.body, {
childList: true,
subtree: true,
})
inspectCheckoutFrame()
const loadingTimeout = setTimeout(() => {
if (isReady) return
if (embeddedCheckoutAttempt === 0) {
console.warn('[StripeProvider] Embedded Checkout stalled, retrying initialization')
retryEmbeddedCheckout()
return
}
console.error('[StripeProvider] Embedded Checkout failed to become ready')
setEmbeddedCheckoutPhase('failed')
}, EMBEDDED_CHECKOUT_TIMEOUT_MS)
return () => {
clearTimeout(loadingTimeout)
checkoutFrameObserver?.disconnect()
documentObserver?.disconnect()
}
}, [
clientSecret,
embeddedCheckoutAttempt,
embeddedCheckoutPhase,
retryEmbeddedCheckout,
])
const fetchStripeCatalog = React.useCallback(async () => { const fetchStripeCatalog = React.useCallback(async () => {
setIsCatalogLoading(true) setIsCatalogLoading(true)
setCatalogError(null) setCatalogError(null)
@@ -410,7 +555,7 @@ const StripeProvider = ({ children }) => {
createSongDownloadCheckout, createSongDownloadCheckout,
createPlaybackDownloadCheckout, createPlaybackDownloadCheckout,
createCoinPackCheckout, createCoinPackCheckout,
openEmbeddedCheckout: setClientSecret, openEmbeddedCheckout,
closeEmbeddedCheckout, closeEmbeddedCheckout,
activeSubscription: activeSubscriptionInfo.subscription, activeSubscription: activeSubscriptionInfo.subscription,
activeSubscriptionInfo, activeSubscriptionInfo,
@@ -428,7 +573,7 @@ const StripeProvider = ({ children }) => {
createSongDownloadCheckout, createSongDownloadCheckout,
createPlaybackDownloadCheckout, createPlaybackDownloadCheckout,
createCoinPackCheckout, createCoinPackCheckout,
setClientSecret, openEmbeddedCheckout,
closeEmbeddedCheckout, closeEmbeddedCheckout,
activeSubscriptionInfo, activeSubscriptionInfo,
isActiveSubscriptionLoading, isActiveSubscriptionLoading,
@@ -441,27 +586,46 @@ const StripeProvider = ({ children }) => {
<StripeContext.Provider value={providerValue}> <StripeContext.Provider value={providerValue}>
{children} {children}
<Overlay <Overlay
isVisible={clientSecret !== null} isVisible={isEmbeddedCheckoutVisible}
setIsVisible={closeEmbeddedCheckout} setIsVisible={closeEmbeddedCheckout}
contentContainerStyle={styles.overlayContentFull} contentContainerStyle={styles.overlayContentFull}
keepMounted={clientSecret !== null}
> >
<View style={[StyleSheet.absoluteFillObject, styles.overlayContent]}> <View style={[StyleSheet.absoluteFillObject, styles.overlayContent]}>
<View style={[styles.embeddedWrapper, styles.embeddedWrapperFull]}> <View style={[styles.embeddedWrapper, styles.embeddedWrapperFull]}>
{clientSecret && stripePromise ? ( {clientSecret && stripePromise ? (
<EmbeddedCheckoutProvider stripe={stripePromise} options={{ clientSecret }}> <EmbeddedCheckoutProvider
<EmbeddedCheckout key={embeddedCheckoutInstanceKey}
onComplete={() => { stripe={stripePromise}
closeEmbeddedCheckout() options={embeddedCheckoutOptions}
fetchActiveSubscription() >
}} <EmbeddedCheckout id="musicland-embedded-checkout" />
/>
</EmbeddedCheckoutProvider> </EmbeddedCheckoutProvider>
) : null} ) : null}
</View> </View>
{embeddedCheckoutPhase === 'loading' ? (
<View style={styles.checkoutLoading}>
<ActivityLoader defaultMessage="Chargement du paiement sécurisé..." />
</View>
) : null}
{embeddedCheckoutPhase === 'failed' ? (
<View style={styles.checkoutFailure}>
<Text style={styles.checkoutFailureTitle}>Le paiement ne sest pas chargé.</Text>
<Text style={styles.checkoutFailureMessage}>
Vérifie ta connexion puis réessaie.
</Text>
<Button
type="primary"
text="Réessayer"
onPress={retryEmbeddedCheckout}
containerStyle={styles.retryButton}
/>
</View>
) : null}
<Button <Button
type="primary" type="primary"
isAbsoluteBottom isAbsoluteBottom
text="Fermer" text="Fermer le paiement"
onPress={closeEmbeddedCheckout} onPress={closeEmbeddedCheckout}
containerStyle={styles.closeButton} containerStyle={styles.closeButton}
textStyle={styles.closeButtonText} textStyle={styles.closeButtonText}
@@ -488,11 +652,52 @@ const styles = StyleSheet.create({
overflow: 'hidden', overflow: 'hidden',
}, },
embeddedWrapperFull: { embeddedWrapperFull: {
width: '100%', position: 'absolute',
height: '100%', top: 0,
right: 0,
bottom: 72,
left: 0,
width: 'auto',
height: 'auto',
maxWidth: '100%', maxWidth: '100%',
borderRadius: 0, borderRadius: 0,
alignSelf: 'stretch', alignSelf: 'stretch',
overflow: 'auto',
zIndex: 1,
},
checkoutLoading: {
...StyleSheet.absoluteFillObject,
alignItems: 'center',
justifyContent: 'center',
paddingBottom: 72,
pointerEvents: 'none',
backgroundColor: 'rgba(7, 17, 27, 0.88)',
zIndex: 2,
},
checkoutFailure: {
...StyleSheet.absoluteFillObject,
alignItems: 'center',
justifyContent: 'center',
gap: 12,
paddingHorizontal: 24,
paddingBottom: 72,
backgroundColor: 'rgba(7, 17, 27, 0.94)',
zIndex: 2,
},
checkoutFailureTitle: {
color: Palette.white,
fontSize: 20,
textAlign: 'center',
},
checkoutFailureMessage: {
color: Palette.white,
fontSize: 15,
opacity: 0.8,
textAlign: 'center',
},
retryButton: {
width: '100%',
maxWidth: 320,
}, },
closeButton: { closeButton: {
height: 56, height: 56,
@@ -503,6 +708,7 @@ const styles = StyleSheet.create({
shadowRadius: 10, shadowRadius: 10,
shadowOffset: { width: 0, height: 6 }, shadowOffset: { width: 0, height: 6 },
elevation: 6, elevation: 6,
zIndex: 3,
}, },
closeButtonText: { closeButtonText: {
color: Palette.white, color: Palette.white,
+19 -281
View File
@@ -1,15 +1,15 @@
import { useRoute } from '@react-navigation/native' import { useRoute } from '@react-navigation/native'
import React, { useState } from 'react' import React, { useState } from 'react'
import { Modal, Pressable, ScrollView, Text, View } from 'react-native' import { Pressable, Text, View } from 'react-native'
import { responsiveHeight } from 'react-native-responsive-dimensions' import { responsiveHeight } from 'react-native-responsive-dimensions'
import { useGlobal } from 'reactn' import { useGlobal } from 'reactn'
import { background } from '../assets' import { background } from '../assets'
import BorderGradientButton from '../components/BorderGradientButton'
import GradientButton from '../components/GradientButton' import GradientButton from '../components/GradientButton'
import { Input } from '../components/Input' import { Input } from '../components/Input'
import ItemContainer from '../components/ItemContainer/ItemContainer' import ItemContainer from '../components/ItemContainer/ItemContainer'
import { checkIfPasswordIsStrongEnough, handleFirebaseError } from '../actions/signupActions' import { checkIfPasswordIsStrongEnough, handleFirebaseError } from '../actions/signupActions'
import firebase, { usersRef } from '../config/firebase' import firebase, { usersRef } from '../config/firebase'
import { REGISTRATION_TERMS_VERSION } from '../data/legal'
import Page from '../layouts/Page' import Page from '../layouts/Page'
import { isWeb } from '../hooks/useLayoutType' import { isWeb } from '../hooks/useLayoutType'
import { Routes } from '../navigation' import { Routes } from '../navigation'
@@ -17,7 +17,7 @@ import { reset } from '../navigation/NavigationService'
import { Palette } from '../styles' import { Palette } from '../styles'
import { FONT_FAMILY } from '../styles/Fonts' import { FONT_FAMILY } from '../styles/Fonts'
const CreatePassword = () => { const CreatePassword = ({ navigation }) => {
const route = useRoute() const route = useRoute()
const email = route?.params?.email || '' const email = route?.params?.email || ''
const firstName = route?.params?.firstName || '' const firstName = route?.params?.firstName || ''
@@ -29,10 +29,8 @@ const CreatePassword = () => {
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [passwordError, setPasswordError] = useState('') const [passwordError, setPasswordError] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [isCguAccepted, setIsCguAccepted] = useState(false)
const [isCguModalVisible, setIsCguModalVisible] = useState(false)
const [hasScrolledCguToEnd, setHasScrolledCguToEnd] = useState(false)
const [, setTooltip] = useGlobal('_tooltip') const [, setTooltip] = useGlobal('_tooltip')
const termsAccepted = route.params?.termsAccepted === true
const handlePasswordChange = (text) => { const handlePasswordChange = (text) => {
setPassword(text) setPassword(text)
@@ -52,31 +50,8 @@ const CreatePassword = () => {
const isPasswordValid = checkIfPasswordIsStrongEnough({ password }) const isPasswordValid = checkIfPasswordIsStrongEnough({ password })
const handleToggleCguAccepted = () => { const handleOpenTerms = () => {
setIsCguAccepted((prev) => !prev) navigation.navigate(Routes.RegistrationTerms, { sourceRouteKey: route.key })
}
const handleOpenCguModal = () => {
setHasScrolledCguToEnd(false)
setIsCguModalVisible(true)
}
const handleCloseCguModal = () => {
setIsCguModalVisible(false)
setHasScrolledCguToEnd(false)
}
const handleCguScroll = ({ nativeEvent }) => {
const { layoutMeasurement, contentOffset, contentSize } = nativeEvent
const paddingToBottom = 20
const contentFits = contentSize.height <= layoutMeasurement.height + 1
const isAtEnd =
layoutMeasurement.height + contentOffset.y >= contentSize.height - paddingToBottom
if (isAtEnd) {
setHasScrolledCguToEnd(true)
} else if (contentFits) {
setHasScrolledCguToEnd(true)
}
} }
const onCreateAccount = async () => { const onCreateAccount = async () => {
@@ -89,11 +64,12 @@ const CreatePassword = () => {
return return
} }
if (!isCguAccepted) { if (!termsAccepted) {
setTooltip({ setTooltip({
text: 'Tu dois accepter les CGU pour continuer', text: 'Tu dois accepter les CGU et CGV pour continuer',
type: 'error', type: 'error',
}) })
handleOpenTerms()
return return
} }
@@ -116,6 +92,8 @@ const CreatePassword = () => {
...(country?.code ? { countryCode: country.code } : {}), ...(country?.code ? { countryCode: country.code } : {}),
...(country?.name ? { countryName: country.name } : {}), ...(country?.name ? { countryName: country.name } : {}),
registrationFlowRequired: true, registrationFlowRequired: true,
termsAcceptedAt: firebase.firestore.FieldValue.serverTimestamp(),
termsVersion: REGISTRATION_TERMS_VERSION,
createdAt: firebase.firestore.FieldValue.serverTimestamp(), createdAt: firebase.firestore.FieldValue.serverTimestamp(),
updatedAt: firebase.firestore.FieldValue.serverTimestamp(), updatedAt: firebase.firestore.FieldValue.serverTimestamp(),
}, },
@@ -207,7 +185,7 @@ const CreatePassword = () => {
}} }}
> >
<Pressable <Pressable
onPress={handleToggleCguAccepted} onPress={handleOpenTerms}
style={{ style={{
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
@@ -225,7 +203,7 @@ const CreatePassword = () => {
borderColor: Palette.white, borderColor: Palette.white,
}} }}
> >
{isCguAccepted ? ( {termsAccepted ? (
<View <View
style={{ style={{
width: 9, width: 9,
@@ -243,10 +221,11 @@ const CreatePassword = () => {
fontFamily: FONT_FAMILY.InterRegular, fontFamily: FONT_FAMILY.InterRegular,
}} }}
> >
J&apos;accepte les {termsAccepted ? 'CGU et CGV acceptées' : 'Lire et accepter les'}
</Text> </Text>
</Pressable> </Pressable>
<Pressable onPress={handleOpenCguModal}> {!termsAccepted ? (
<Pressable onPress={handleOpenTerms}>
<Text <Text
style={{ style={{
fontSize: 14, fontSize: 14,
@@ -255,9 +234,10 @@ const CreatePassword = () => {
textDecorationLine: 'underline', textDecorationLine: 'underline',
}} }}
> >
CGU CGU et CGV
</Text> </Text>
</Pressable> </Pressable>
) : null}
</View> </View>
</View> </View>
<GradientButton <GradientButton
@@ -267,253 +247,11 @@ const CreatePassword = () => {
alignSelf: 'center', alignSelf: 'center',
}} }}
onPress={onCreateAccount} onPress={onCreateAccount}
disabled={loading || !email || !isPasswordValid || !isCguAccepted} disabled={loading || !email || !isPasswordValid || !termsAccepted}
/> />
</View> </View>
</ItemContainer> </ItemContainer>
</View> </View>
<Modal
animationType="slide"
visible={isCguModalVisible}
transparent
onRequestClose={handleCloseCguModal}
>
<View
style={{
flex: 1,
backgroundColor: Palette.transparentBlack,
justifyContent: 'center',
paddingHorizontal: 20,
paddingVertical: 40,
alignItems: 'center',
}}
>
<View
style={{
backgroundColor: Palette.darkPurple,
borderRadius: 16,
padding: 20,
paddingTop: 28,
maxHeight: '75%',
width: '90%',
flexShrink: 1,
}}
>
<Pressable
onPress={handleCloseCguModal}
style={{
position: 'absolute',
top: 12,
right: 12,
padding: 6,
zIndex: 2,
}}
>
<Text
style={{
fontSize: 16,
color: Palette.gray,
fontFamily: FONT_FAMILY.InterSemiBold,
}}
>
X
</Text>
</Pressable>
<View style={{ gap: 16, flex: 1 }}>
<Text
style={{
fontSize: 20,
color: Palette.white,
fontFamily: FONT_FAMILY.InterSemiBold,
textAlign: 'center',
}}
>
Conditions Générales d&apos;Utilisation
</Text>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={{ gap: 16, paddingBottom: 12 }}
onScroll={handleCguScroll}
onMomentumScrollEnd={handleCguScroll}
scrollEventThrottle={16}
style={{ flex: 1 }}
>
<Text
style={{
fontSize: 14,
color: Palette.gray,
fontFamily: FONT_FAMILY.InterRegular,
lineHeight: 20,
}}
>
Cupidatat incididunt aute aute velit deserunt labore excepteur eu velit cillum
est. Laboris in sunt ea Lorem culpa velit nisi ad elit esse voluptate id
adipisicing nulla magna. Duis qui fugiat consequat elit velit aute ad.
Reprehenderit proident duis exercitation velit proident aute minim. Anim
reprehenderit mollit consectetur id sint adipisicing pariatur tempor sit pariatur
proident nostrud. Sit culpa tempor enim ut consectetur sunt aute est reprehenderit
incididunt incididunt in ullamco consectetur. Eiusmod non incididunt proident
eiusmod. Esse Lorem ut amet in est id aute consectetur.
</Text>
<Text
style={{
fontSize: 14,
color: Palette.gray,
fontFamily: FONT_FAMILY.InterRegular,
lineHeight: 20,
}}
>
Aliqua est sint consectetur occaecat exercitation. Elit nisi velit ad qui eiusmod
reprehenderit proident labore nostrud labore consequat enim fugiat non. Eiusmod
occaecat pariatur deserunt velit elit sint irure fugiat excepteur labore ad velit
ex deserunt laborum. Aliqua excepteur reprehenderit nostrud aliqua pariatur aliqua
excepteur Lorem consectetur. Ut est culpa ullamco ipsum Lorem ullamco ea labore
anim.
</Text>
<Text
style={{
fontSize: 14,
color: Palette.gray,
fontFamily: FONT_FAMILY.InterRegular,
lineHeight: 20,
}}
>
Et ut ex duis tempor amet aliquip cupidatat sit sit. Aliqua consectetur amet duis
fugiat nulla duis culpa exercitation reprehenderit. Ea voluptate proident ad elit
pariatur do. Culpa incididunt deserunt dolor ut officia aliquip ut occaecat
eiusmod sint veniam. Deserunt consequat adipisicing aliquip velit labore fugiat
aute culpa id sint. Adipisicing eu commodo ex do aliqua in labore laboris sit sunt
adipisicing excepteur. Do sint velit veniam pariatur consequat proident cupidatat
in incididunt ullamco.
</Text>
<Text
style={{
fontSize: 14,
color: Palette.gray,
fontFamily: FONT_FAMILY.InterRegular,
lineHeight: 20,
}}
>
Culpa eu cillum fugiat elit eiusmod enim. Cupidatat culpa aliquip culpa et tempor
est velit. Aute id deserunt non ut minim deserunt adipisicing sit veniam eu id
incididunt adipisicing dolore. Eiusmod nisi excepteur est voluptate consequat
reprehenderit non exercitation commodo aute.
</Text>
<Text
style={{
fontSize: 14,
color: Palette.gray,
fontFamily: FONT_FAMILY.InterRegular,
lineHeight: 20,
}}
>
Eu culpa enim dolore anim ipsum eu veniam mollit dolore officia cupidatat laborum
officia do. Ad proident in anim nisi exercitation consequat exercitation occaecat
consequat ut laborum esse consectetur ullamco. Enim elit sit in id velit quis
nostrud nostrud eu labore aute reprehenderit voluptate deserunt. Minim voluptate
et duis voluptate enim duis.
</Text>
<Text
style={{
fontSize: 14,
color: Palette.gray,
fontFamily: FONT_FAMILY.InterRegular,
lineHeight: 20,
}}
>
Minim laboris deserunt minim ex. Duis aliquip cillum proident. Magna velit nisi
fugiat. Non id proident pariatur elit in exercitation et amet id qui ad laborum
nulla ullamco ea.
</Text>
<Text
style={{
fontSize: 14,
color: Palette.gray,
fontFamily: FONT_FAMILY.InterRegular,
lineHeight: 20,
}}
>
Cillum nisi aute dolore culpa est veniam cupidatat sunt sint ipsum. Amet ullamco
minim non voluptate cillum dolore sunt irure nulla pariatur excepteur voluptate
id. Commodo ullamco aliqua non ea mollit ullamco do minim dolor magna. Ipsum eu
minim quis laborum do dolore labore eiusmod et. Ullamco officia velit anim.
Exercitation voluptate reprehenderit ex et do eu fugiat tempor do cillum ad.
</Text>
<Text
style={{
fontSize: 14,
color: Palette.gray,
fontFamily: FONT_FAMILY.InterRegular,
lineHeight: 20,
}}
>
Cupidatat consectetur excepteur commodo laborum incididunt laboris minim dolore ut
ipsum dolor ullamco culpa aliquip. Cillum proident quis consectetur voluptate
labore nisi Lorem pariatur in esse. Amet ipsum mollit officia fugiat Lorem ipsum
elit officia. Esse reprehenderit magna quis irure sit consectetur dolore sunt
mollit aliquip eiusmod voluptate amet. Ex irure culpa ea cupidatat nulla ea labore
aute occaecat consequat consectetur cillum amet. Velit ad do occaecat non elit
quis. Amet id reprehenderit ullamco amet tempor deserunt exercitation elit
consectetur minim aliqua. Cillum do aliquip do ea ipsum veniam deserunt in ipsum
pariatur nisi proident et ut deserunt.
</Text>
<Text
style={{
fontSize: 14,
color: Palette.gray,
fontFamily: FONT_FAMILY.InterRegular,
lineHeight: 20,
}}
>
Mollit et adipisicing velit tempor deserunt excepteur fugiat eiusmod adipisicing.
Tempor do ex duis dolor deserunt cillum officia est nisi mollit fugiat. Amet duis
ea laboris officia aliquip id sint voluptate consectetur velit elit reprehenderit
nostrud exercitation. Ea dolore adipisicing nulla incididunt laboris sint commodo
non mollit eiusmod. Tempor nulla eu laborum tempor veniam laboris consequat non
consequat exercitation pariatur velit. Voluptate magna mollit esse incididunt id.
Pariatur eu irure esse ullamco fugiat culpa.
</Text>
<Text
style={{
fontSize: 14,
color: Palette.gray,
fontFamily: FONT_FAMILY.InterRegular,
lineHeight: 20,
}}
>
Sint dolore aliqua pariatur do mollit occaecat deserunt qui proident non
exercitation mollit cillum culpa. Est cupidatat consequat ea commodo laborum
labore sunt excepteur labore aute est amet anim. Tempor deserunt labore mollit
enim officia aliqua occaecat. Ullamco mollit qui mollit do ex irure enim.
</Text>
</ScrollView>
{hasScrolledCguToEnd ? (
<View style={{ gap: 10 }}>
<GradientButton
title="Accepter sans réserve"
onPress={() => {
setIsCguAccepted(true)
handleCloseCguModal()
}}
/>
<BorderGradientButton title="Refuser" onPress={handleCloseCguModal} />
</View>
) : (
<Text
style={{
fontSize: 12,
color: Palette.gray,
fontFamily: FONT_FAMILY.InterRegular,
textAlign: 'center',
}}
>
Fais défiler jusqu&apos;en bas pour afficher les actions
</Text>
)}
</View>
</View>
</View>
</Modal>
</Page> </Page>
) )
} }
+18 -3
View File
@@ -72,7 +72,12 @@ const HitParade = () => {
}, [selectedLanguage]) }, [selectedLanguage])
const { data: allSongs } = useDataFromRef({ const { data: allSongs } = useDataFromRef({
ref: allSongsRef, ref: allSongsRef,
format: (docs) => docs.filter((item) => item?.hasPlayback !== true), format: (docs) =>
docs.filter(
(item) =>
item?.songPublishedOnMusicLand !== false &&
(item?.hasPlayback !== true || item?.playbackPublishedOnMusicLand === false)
),
simpleRef: false, simpleRef: false,
listener: true, listener: true,
condition: true, condition: true,
@@ -81,7 +86,12 @@ const HitParade = () => {
const { data: topMonthSongs } = useDataFromRef({ const { data: topMonthSongs } = useDataFromRef({
ref: projectsRef.where('monthViews', '>', 0).orderBy('monthViews', 'desc').limit(20), ref: projectsRef.where('monthViews', '>', 0).orderBy('monthViews', 'desc').limit(20),
format: (docs) => docs.filter((item) => item?.hasPlayback !== true), format: (docs) =>
docs.filter(
(item) =>
item?.songPublishedOnMusicLand !== false &&
(item?.hasPlayback !== true || item?.playbackPublishedOnMusicLand === false)
),
simpleRef: false, simpleRef: false,
listener: false, listener: false,
condition: selectedLanguage === 'all', condition: selectedLanguage === 'all',
@@ -90,7 +100,12 @@ const HitParade = () => {
const { data: allTimeSongs } = useDataFromRef({ const { data: allTimeSongs } = useDataFromRef({
ref: projectsRef.where('views', '>', 0).orderBy('views', 'desc').limit(20), ref: projectsRef.where('views', '>', 0).orderBy('views', 'desc').limit(20),
format: (docs) => docs.filter((item) => item?.hasPlayback !== true), format: (docs) =>
docs.filter(
(item) =>
item?.songPublishedOnMusicLand !== false &&
(item?.hasPlayback !== true || item?.playbackPublishedOnMusicLand === false)
),
simpleRef: false, simpleRef: false,
listener: false, listener: false,
condition: selectedLanguage === 'all', condition: selectedLanguage === 'all',
@@ -5,6 +5,7 @@ import { img } from '../../../assets'
import { Palette, Style } from '../../../styles' import { Palette, Style } from '../../../styles'
import { FONT_FAMILY } from '../../../styles/Fonts' import { FONT_FAMILY } from '../../../styles/Fonts'
import SubscriptionBadge from '../../../components/SubscriptionBadge' import SubscriptionBadge from '../../../components/SubscriptionBadge'
import useResolvedMediaUri from '../../../hooks/useResolvedMediaUri'
const PlaybacksCard = ({ const PlaybacksCard = ({
rank = 1, rank = 1,
@@ -18,6 +19,7 @@ const PlaybacksCard = ({
const resolveUri = (value) => const resolveUri = (value) =>
typeof value === 'string' && value.trim().length > 0 ? value : null typeof value === 'string' && value.trim().length > 0 ? value : null
const imageUri = resolveUri(thumbnailUrl) ?? resolveUri(coverUrl) const imageUri = resolveUri(thumbnailUrl) ?? resolveUri(coverUrl)
const resolvedImageUri = useResolvedMediaUri(imageUri)
return ( return (
<Pressable onPress={onPress}> <Pressable onPress={onPress}>
@@ -63,7 +65,7 @@ const PlaybacksCard = ({
{rank} {rank}
</Text> </Text>
<Image <Image
source={imageUri ? { uri: imageUri } : img.placeholder3} source={resolvedImageUri ? { uri: resolvedImageUri } : img.placeholder3}
style={{ width: 67, height: 108, borderRadius: 16 }} style={{ width: 67, height: 108, borderRadius: 16 }}
/> />
<View style={{ flex: 1 }}> <View style={{ flex: 1 }}>
+5 -2
View File
@@ -7,6 +7,7 @@ import Style, { size } from '../../../styles/Style'
import { Palette } from '../../../styles' import { Palette } from '../../../styles'
import { FONT_FAMILY } from '../../../styles/Fonts' import { FONT_FAMILY } from '../../../styles/Fonts'
import SubscriptionBadge from '../../../components/SubscriptionBadge' import SubscriptionBadge from '../../../components/SubscriptionBadge'
import useResolvedMediaUri from '../../../hooks/useResolvedMediaUri'
const SongCard = ({ const SongCard = ({
rank = 1, rank = 1,
@@ -17,6 +18,8 @@ const SongCard = ({
subscriptionLevel = null, subscriptionLevel = null,
views = null, views = null,
}) => { }) => {
const resolvedCoverUrl = useResolvedMediaUri(coverUrl)
return ( return (
<Pressable <Pressable
onPress={onPress || undefined} onPress={onPress || undefined}
@@ -25,9 +28,9 @@ const SongCard = ({
gap: 6, gap: 6,
}} }}
> >
{coverUrl ? ( {resolvedCoverUrl ? (
<ExpoImage <ExpoImage
source={{ uri: coverUrl }} source={{ uri: resolvedCoverUrl }}
cachePolicy="memory-disk" cachePolicy="memory-disk"
priority="high" priority="high"
contentFit="cover" contentFit="cover"
-10
View File
@@ -72,16 +72,6 @@ const STAGE_CARD_CONTENT = [
textAlign: 'left', textAlign: 'left',
lockSide: 'right', lockSide: 'right',
}, },
{
key: 'publisher',
step: 'ÉTAPE 4',
description:
'Je suis Mr Benhaï, Producteur de MusicLand, et je vais te faire une proposition qui pourrait tintéresser. On se retrouve à la sortie du studio !',
image: cardsImg.production,
imagePosition: 'right',
textAlign: 'right',
lockSide: 'left',
},
] ]
const ACTIVE_SUBSCRIPTION_STATUSES = new Set(['trialing', 'active', 'past_due', 'unpaid']) const ACTIVE_SUBSCRIPTION_STATUSES = new Set(['trialing', 'active', 'past_due', 'unpaid'])
+62 -67
View File
@@ -1,19 +1,8 @@
import AsyncStorage from '@react-native-async-storage/async-storage' import AsyncStorage from '@react-native-async-storage/async-storage'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useMemo, useState } from 'react'
import { import { Image, StyleSheet, Text, TouchableOpacity, View } from 'react-native'
ActivityIndicator, import { background, icons, videos } from '../assets'
Image,
Platform,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native'
import { background, icons } from '../assets'
import EntryJingle from '../components/EntryJingle'
import FullscreenIntroVideo from '../components/FullscreenIntroVideo' import FullscreenIntroVideo from '../components/FullscreenIntroVideo'
import { videosRef } from '../config/firebase'
import useDataFromRef from '../hooks/useDataFromRef'
import Page from '../layouts/Page' import Page from '../layouts/Page'
import { Routes } from '../navigation' import { Routes } from '../navigation'
import { navigate, reset } from '../navigation/NavigationService' import { navigate, reset } from '../navigation/NavigationService'
@@ -22,45 +11,25 @@ import { Palette } from '../styles'
import { FONT_FAMILY } from '../styles/Fonts' import { FONT_FAMILY } from '../styles/Fonts'
const LANGUAGE_STORAGE_KEY = 'preferredLanguage' 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 { 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 [selectedLanguage, setSelectedLanguage] = useState(null)
const continuationHandledRef = useRef(false)
const isAuthenticated = !!currentUID 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(() => { const openRegistration = useCallback(() => {
navigate(Routes.Register) navigate(Routes.Register)
}, []) }, [])
const handleSelectLanguage = useCallback(async (language) => { const openMainMenu = useCallback(() => {
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
}
reset({ reset({
index: 0, index: 0,
routes: [ routes: [
@@ -73,33 +42,49 @@ export default function LandingPage() {
}, },
], ],
}) })
}, [isAuthenticated]) }, [])
useEffect(() => { const handleJingleClose = useCallback(() => {
if ( if (isAuthenticated) {
!selectedLanguage || openMainMenu()
welcomeVideoLoading ||
continuationHandledRef.current ||
isAuthenticated
) {
return return
} }
setPhase(FLOW_PHASE.LANGUAGE)
}, [isAuthenticated, openMainMenu])
continuationHandledRef.current = true const handleSelectLanguage = useCallback(async (language) => {
const nextVideoUrl = try {
Platform.OS === 'web' ? welcomeVideo?.registrationIntroWeb : welcomeVideo?.registrationIntro await AsyncStorage.setItem(LANGUAGE_STORAGE_KEY, language)
} catch (error) {
if (typeof nextVideoUrl === 'string' && nextVideoUrl.trim().length > 0) { console.warn('LandingPage: failed to persist language', error)
setVideoUrl(nextVideoUrl)
return
} }
setSelectedLanguage(language)
setPhase(FLOW_PHASE.WELCOME_VIDEO)
}, [])
openRegistration() const handleWelcomeVideoClose = useCallback(() => {
}, [isAuthenticated, openRegistration, selectedLanguage, welcomeVideo, welcomeVideoLoading]) 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 isJingle = phase === FLOW_PHASE.JINGLE
const skipLabel = selectedLanguage === 'en' ? 'Skip video' : 'Passer la vidéo'
const playLabel = isJingle
? 'Activer le son / Enable sound'
: selectedLanguage === 'en'
? 'Play video with sound'
: 'Lire la vidéo avec le son'
return ( return (
<> <>
<EntryJingle active={!isAuthenticated && !selectedLanguage} />
<Page title="Landing Page" backgroundImg={background.homeBGWeb}> <Page title="Landing Page" backgroundImg={background.homeBGWeb}>
<View <View
style={{ style={{
@@ -109,7 +94,7 @@ export default function LandingPage() {
}} }}
> >
<View style={styles.content}> <View style={styles.content}>
{!selectedLanguage && ( {phase === FLOW_PHASE.LANGUAGE ? (
<View style={styles.languageRow}> <View style={styles.languageRow}>
<TouchableOpacity <TouchableOpacity
style={[styles.languageButton]} style={[styles.languageButton]}
@@ -126,14 +111,24 @@ export default function LandingPage() {
<Text style={styles.languageText}>English</Text> <Text style={styles.languageText}>English</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
)}
{selectedLanguage && welcomeVideoLoading ? (
<ActivityIndicator color={Palette.white} size="large" />
) : null} ) : null}
</View> </View>
</View> </View>
</Page> </Page>
<FullscreenIntroVideo url={videoUrl} visible={!!videoUrl} onClose={handleVideoClose} /> {activeVideo ? (
<FullscreenIntroVideo
key={phase}
url={activeVideo}
visible
onClose={activeVideoClose}
contentFit="contain"
allowSkip={!isJingle}
requireAudio={!isJingle}
offerSoundActivation={isJingle}
skipLabel={skipLabel}
playLabel={playLabel}
/>
) : null}
</> </>
) )
} }
+1 -1
View File
@@ -138,7 +138,7 @@ const MusicDetails = ({ route }) => {
}, [project?.musicTimestamps, project?.songIndex]) }, [project?.musicTimestamps, project?.songIndex])
const flatLines = useMemo(() => sections.flatMap((s) => s.lines), [sections]) const flatLines = useMemo(() => sections.flatMap((s) => s.lines), [sections])
const visibleTimeS = (positionMs || 0) / 1000 + 0.3 // Offset de 300ms pour fluidité visuelle const visibleTimeS = (positionMs || 0) / 1000
const currentLineIdx = useMemo(() => { const currentLineIdx = useMemo(() => {
if (!flatLines.length) return -1 if (!flatLines.length) return -1
+1 -2
View File
@@ -548,8 +548,7 @@ const MusicDetails = ({ route }) => {
// Current time in seconds for highlighting // Current time in seconds for highlighting
const currentTimeS = useMemo(() => Math.max(0, (positionMs || 0) / 1000), [positionMs]) const currentTimeS = useMemo(() => Math.max(0, (positionMs || 0) / 1000), [positionMs])
// Preview lead: show words 0.5s earlier const visibleTimeS = currentTimeS
const visibleTimeS = useMemo(() => Math.max(0, currentTimeS + 0.5), [currentTimeS])
// Helpers to group aligned words into sections and timed lines // Helpers to group aligned words into sections and timed lines
const SECTION_TAG_REGEX = /^\s*\[([^\]]+)\]\s*$/i const SECTION_TAG_REGEX = /^\s*\[([^\]]+)\]\s*$/i
const SECTION_TAG_LEADING_REGEX = /^\s*\[([^\]]+)\]\s*/i const SECTION_TAG_LEADING_REGEX = /^\s*\[([^\]]+)\]\s*/i
+4 -2
View File
@@ -6,6 +6,7 @@ import { responsiveWidth } from 'react-native-responsive-dimensions'
import { useGlobal } from 'reactn' import { useGlobal } from 'reactn'
import { icons, img } from '../../../assets' import { icons, img } from '../../../assets'
import PressableScale from '../../../components/PressableScale' import PressableScale from '../../../components/PressableScale'
import useResolvedMediaUri from '../../../hooks/useResolvedMediaUri'
import { LIKE_TARGET, toggleProjectLike } from '../../../utils/likes' import { LIKE_TARGET, toggleProjectLike } from '../../../utils/likes'
import { ensureAuthenticated } from '../../../utils/authRedirect' import { ensureAuthenticated } from '../../../utils/authRedirect'
import { Palette, Style } from '../../../styles' import { Palette, Style } from '../../../styles'
@@ -23,6 +24,7 @@ const MusicCard = ({
likeTarget = LIKE_TARGET.SONG, likeTarget = LIKE_TARGET.SONG,
}) => { }) => {
const [currentUID] = useGlobal('currentUID') const [currentUID] = useGlobal('currentUID')
const resolvedImageUri = useResolvedMediaUri(imageUri)
const [selected, setSelected] = useState(false) const [selected, setSelected] = useState(false)
const [layout, setLayout] = useState(null) const [layout, setLayout] = useState(null)
const [menuPos, setMenuPos] = useState({ const [menuPos, setMenuPos] = useState({
@@ -113,9 +115,9 @@ const MusicCard = ({
onPress={onPress} onPress={onPress}
onLayout={(e) => setLayout(e.nativeEvent.layout)} onLayout={(e) => setLayout(e.nativeEvent.layout)}
> >
{imageUri ? ( {resolvedImageUri ? (
<ExpoImage <ExpoImage
source={{ uri: imageUri }} source={{ uri: resolvedImageUri }}
cachePolicy="memory-disk" cachePolicy="memory-disk"
priority="high" priority="high"
contentFit="cover" contentFit="cover"
@@ -112,7 +112,10 @@ const SearchResultsList = ({
return musics return musics
} }
return musics.filter((project) => project?.hasPlayback !== true) return musics.filter(
(project) =>
project?.hasPlayback !== true || project?.playbackPublishedOnMusicLand === false
)
}, [musics, selected]) }, [musics, selected])
const shouldShowMusics = const shouldShowMusics =
+1 -9
View File
@@ -2,7 +2,7 @@ import FontAwesome from '@expo/vector-icons/FontAwesome'
import { BlurView } from 'expo-blur' import { BlurView } from 'expo-blur'
import React, { useCallback, useMemo } from 'react' import React, { useCallback, useMemo } from 'react'
import { Image, Platform, Pressable, Text, View } from 'react-native' import { Image, Platform, Pressable, Text, View } from 'react-native'
import { ai, background, cardsImg } from '../assets' import { ai, background } from '../assets'
import Page from '../layouts/Page' import Page from '../layouts/Page'
import { navigate } from '../navigation/NavigationService' import { navigate } from '../navigation/NavigationService'
import { useUserData } from '../providers/UserDataProvider' import { useUserData } from '../providers/UserDataProvider'
@@ -36,14 +36,6 @@ const CREATE_DATA = [
desc: "Theo t'accompagne pour créer ton playback.", desc: "Theo t'accompagne pour créer ton playback.",
type: 'Director', type: 'Director',
}, },
{
stageKey: 'publisher',
img: cardsImg.production,
bg: background.productionBG2,
label: 'Publication',
desc: 'Publions ta vidéo sur YouTube !',
type: 'Producteur',
},
] ]
const NewMusicOptions = () => { const NewMusicOptions = () => {
+16 -4
View File
@@ -46,7 +46,11 @@ const Playback = ({ route, navigation }) => {
const onPressRecord = useCallback(() => { const onPressRecord = useCallback(() => {
if (isManualBlocked) return if (isManualBlocked) return
navigate(Routes.RecordPlayback, { project, returnToAdventureModal }) navigate(Routes.PlaybackImageRightsConsent, {
project,
flow: 'record',
returnToAdventureModal,
})
}, [isManualBlocked, project, returnToAdventureModal]) }, [isManualBlocked, project, returnToAdventureModal])
const onPressPlaybackAi = useCallback(() => { const onPressPlaybackAi = useCallback(() => {
@@ -54,8 +58,12 @@ const Playback = ({ route, navigation }) => {
navigate(Routes.PlaybackAiStatus, { project }) navigate(Routes.PlaybackAiStatus, { project })
return return
} }
navigate(Routes.PlaybackAi, { project }) navigate(Routes.PlaybackImageRightsConsent, {
}, [project]) project,
flow: 'ai',
returnToAdventureModal,
})
}, [project, returnToAdventureModal])
return ( return (
<Page <Page
@@ -86,7 +94,11 @@ const Playback = ({ route, navigation }) => {
onPress={onPressRecord} onPress={onPressRecord}
disabled={!project || isManualBlocked} disabled={!project || isManualBlocked}
/> />
<BorderGradientButton title={aiButtonLabel} onPress={onPressPlaybackAi} disabled={!project} /> <BorderGradientButton
title={aiButtonLabel}
onPress={onPressPlaybackAi}
disabled={!project}
/>
{isManualBlocked ? ( {isManualBlocked ? (
<Text style={styles.blockedText}> <Text style={styles.blockedText}>
Le playback manuel est bloqué tant que le playback IA est en cours ou en attente de Le playback manuel est bloqué tant que le playback IA est en cours ou en attente de
+72 -12
View File
@@ -1,7 +1,7 @@
import * as ImagePicker from 'expo-image-picker' import * as ImagePicker from 'expo-image-picker'
import useMinuit from 'react-native-minuit/src/hooks/useMinuit.js' import useMinuit from 'react-native-minuit/src/hooks/useMinuit.js'
import React, { useMemo, useState } from 'react' 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 { background } from '../../assets'
import ActivityLoader from '../../components/ActivityLoader' import ActivityLoader from '../../components/ActivityLoader'
import BorderGradientButton from '../../components/BorderGradientButton' import BorderGradientButton from '../../components/BorderGradientButton'
@@ -19,21 +19,50 @@ import { PLAYBACK_AI_STATUS } from '../../utils/playbackAi'
import CreateLyricsHeader from '../Writing/components/CreateLyricsHeader' import CreateLyricsHeader from '../Writing/components/CreateLyricsHeader'
const FUNCTIONS_REGION = 'europe-west1' const FUNCTIONS_REGION = 'europe-west1'
const PHOTO_UPLOAD_TIMEOUT_MS = 120000
const HEYGEN_START_TIMEOUT_MS = 60000
const trimString = (value) => (typeof value === 'string' ? value.trim() : '') const trimString = (value) => (typeof value === 'string' ? value.trim() : '')
const isRemoteUrl = (value) => /^https?:\/\//i.test(trimString(value)) const isRemoteUrl = (value) => /^https?:\/\//i.test(trimString(value))
const resolveProject = ({ routeProject, selectedProject }) => { const resolveProject = ({ routeProject, selectedProject }) => {
if (routeProject?.id && selectedProject?.id === routeProject.id) { if (routeProject && typeof routeProject === 'object' && routeProject.id) {
if (selectedProject?.id === routeProject.id) {
return selectedProject return selectedProject
} }
return routeProject || null
return routeProject
}
if (selectedProject?.id) {
return selectedProject
}
return null
} }
const getInitialPhotoUri = ({ routePhotoUrl, project }) => const getInitialPhotoUri = ({ routePhotoUrl, project }) =>
trimString(routePhotoUrl) || trimString(project?.playbackPhotoUrl) || null trimString(routePhotoUrl) || trimString(project?.playbackPhotoUrl) || null
const getImageExtension = (fileName) => {
const normalizedFileName = trimString(fileName)
const extension = normalizedFileName.includes('.')
? normalizedFileName.split('.').pop().toLowerCase()
: ''
return /^[a-z0-9]+$/.test(extension) ? extension : 'jpg'
}
const withTimeout = (promise, timeoutMs, message) => {
let timeoutId
const timeoutPromise = new Promise((_, reject) => {
timeoutId = setTimeout(() => reject(new Error(message)), timeoutMs)
})
return Promise.race([promise, timeoutPromise]).finally(() => clearTimeout(timeoutId))
}
const callStartPlaybackGeneration = (payload) => { const callStartPlaybackGeneration = (payload) => {
const callable = getFunctionsClient(FUNCTIONS_REGION).httpsCallable('heygen-startPlaybackGeneration') const callable = getFunctionsClient(FUNCTIONS_REGION).httpsCallable('heygen-startPlaybackGeneration')
return callable(payload) return callable(payload)
@@ -49,6 +78,7 @@ const PlaybackAi = ({ route }) => {
project: resolveProject({ routeProject, selectedProject }), project: resolveProject({ routeProject, selectedProject }),
}) })
) )
const [selectedPhotoAsset, setSelectedPhotoAsset] = useState(null)
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const project = useMemo( const project = useMemo(
() => resolveProject({ routeProject, selectedProject }), () => resolveProject({ routeProject, selectedProject }),
@@ -64,9 +94,10 @@ const PlaybackAi = ({ route }) => {
quality: 1, quality: 1,
}) })
const nextUri = result?.assets?.[0]?.uri || null const asset = result?.assets?.[0]
if (nextUri) { if (asset?.uri) {
setSelectedPhotoUri(nextUri) setSelectedPhotoUri(asset.uri)
setSelectedPhotoAsset(asset)
} }
} catch (error) { } catch (error) {
setTooltip({ setTooltip({
@@ -77,6 +108,8 @@ const PlaybackAi = ({ route }) => {
} }
const handleGenerate = async () => { const handleGenerate = async () => {
let step = 'validation'
try { try {
if (!project?.id) { if (!project?.id) {
throw new Error('Projet introuvable') throw new Error('Projet introuvable')
@@ -94,24 +127,46 @@ const PlaybackAi = ({ route }) => {
let photoUrl = trimString(selectedPhotoUri) let photoUrl = trimString(selectedPhotoUri)
if (!isRemoteUrl(photoUrl)) { if (!isRemoteUrl(photoUrl)) {
const extension = trimString(selectedPhotoUri.split('.').pop()) || 'jpg' step = 'photo_upload'
const uploadResult = await uploadFileToFirebase({ const extension = getImageExtension(selectedPhotoAsset?.fileName)
const path = `users/${currentUID}/projects/${project.id}/heygen/photo-${Date.now()}.${extension}`
console.log('[PlaybackAi] photo upload start', {
path,
fileSize: selectedPhotoAsset?.fileSize || null,
platform: Platform.OS,
})
const uploadResult = await withTimeout(
uploadFileToFirebase({
uri: selectedPhotoUri, uri: selectedPhotoUri,
path: `users/${currentUID}/projects/${project.id}/heygen/photo-${Date.now()}.${extension}`, path,
shouldCompress: true, shouldCompress: true,
fileType: 'IMAGE', fileType: 'IMAGE',
}) blob: Platform.OS === 'web' ? selectedPhotoAsset?.file : null,
}),
PHOTO_UPLOAD_TIMEOUT_MS,
"L'envoi de la photo a dépassé 2 minutes. Vérifie ta connexion puis réessaie."
)
photoUrl = trimString(uploadResult?.resultURI) photoUrl = trimString(uploadResult?.resultURI)
console.log('[PlaybackAi] photo upload complete', { path })
} }
if (!photoUrl) { if (!photoUrl) {
throw new Error("Impossible d'envoyer la photo vers Firebase") throw new Error("Impossible d'envoyer la photo vers Firebase")
} }
await callStartPlaybackGeneration({ step = 'heygen_start'
console.log('[PlaybackAi] HeyGen request start', { projectId: project.id })
await withTimeout(
callStartPlaybackGeneration({
projectId: project.id, projectId: project.id,
photoUrl, photoUrl,
}) }),
HEYGEN_START_TIMEOUT_MS,
'HeyGen ne répond pas après 1 minute. Réessaie dans quelques instants.'
)
console.log('[PlaybackAi] HeyGen request accepted', { projectId: project.id })
navigate(Routes.PlaybackAiStatus, { navigate(Routes.PlaybackAiStatus, {
project: { project: {
@@ -122,6 +177,11 @@ const PlaybackAi = ({ route }) => {
}, },
}) })
} catch (error) { } catch (error) {
console.error('[PlaybackAi] generation failed', {
step,
code: error?.code,
message: error?.message,
})
setTooltip({ setTooltip({
type: 'error', type: 'error',
text: error?.message || 'Erreur lors du lancement du playback IA', text: error?.message || 'Erreur lors du lancement du playback IA',
@@ -0,0 +1,150 @@
import React, { useMemo, useState } from 'react'
import { ScrollView, StyleSheet, Text, View } from 'react-native'
import useMinuit from 'react-native-minuit/src/hooks/useMinuit.js'
import { background } from '../../assets'
import AppCheckbox from '../../components/AppCheckbox'
import GradientButton from '../../components/GradientButton'
import MusicLandHeader from '../../components/MusicLandHeader'
import { projectsRef, serverTimestamp } from '../../config/firebase'
import Page from '../../layouts/Page'
import { Routes } from '../../navigation'
import { goBack, navigate } from '../../navigation/NavigationService'
import { useUserData } from '../../providers/UserDataProvider'
import { gutters, Palette } from '../../styles'
import { FONT_FAMILY } from '../../styles/Fonts'
import CreateLyricsHeader from '../Writing/components/CreateLyricsHeader'
const PlaybackImageRightsConsent = ({ route }) => {
const { project: routeProject, flow, returnToAdventureModal = false } = route.params || {}
const { selectedProject } = useUserData() || {}
const { setTooltip } = useMinuit()
const [hasAcceptedImageRights, setHasAcceptedImageRights] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const project = useMemo(() => {
if (routeProject?.id && selectedProject?.id === routeProject.id) {
return selectedProject
}
return routeProject || null
}, [routeProject, selectedProject])
const handleContinue = async () => {
if (!hasAcceptedImageRights || isSaving) return
if (!project?.id || (flow !== 'record' && flow !== 'ai')) {
setTooltip({
type: 'error',
text: "Impossible de poursuivre vers l'espace Playback.",
})
return
}
try {
setIsSaving(true)
await projectsRef.doc(project.id).set(
{
imageRightsAccepted: true,
imageRightsAcceptedAt: serverTimestamp(),
},
{ merge: true }
)
const params = { project, returnToAdventureModal }
navigate(flow === 'record' ? Routes.RecordPlayback : Routes.PlaybackAi, params)
} catch (error) {
setTooltip({
type: 'error',
text: error?.message || "Impossible d'enregistrer la confirmation.",
})
} finally {
setIsSaving(false)
}
}
return (
<Page backgroundImg={background.playbackBG2} backgroundColor="#07111B" headerType="NONE">
<MusicLandHeader progress={28} onPressBack={goBack} />
<ScrollView contentContainerStyle={styles.container} showsVerticalScrollIndicator={false}>
<CreateLyricsHeader
title="Droit à l'image"
subTitle="Cette confirmation est nécessaire pour continuer."
/>
<View style={styles.disclaimerCard}>
<Text style={styles.disclaimerTitle}>Avant de créer ton playback</Text>
<Text style={styles.disclaimerText}>
La photo ou la vidéo utilisée doit représenter une personne qui a donné son accord.
N'utilise pas l'image d'un tiers sans son autorisation.
</Text>
<Text style={styles.disclaimerText}>
Si une autre personne apparaît dans le contenu, tu dois disposer de son autorisation
pour l'enregistrement, le traitement et, si tu le choisis plus tard, la publication sur
MusicLand.
</Text>
</View>
<View style={styles.consentContainer}>
<AppCheckbox
selected={hasAcceptedImageRights}
onPress={() => setHasAcceptedImageRights((current) => !current)}
label="Je confirme disposer des droits et autorisations nécessaires sur toutes les personnes représentées."
/>
</View>
<GradientButton
title="Continuer vers mon playback"
onPress={handleContinue}
disabled={!hasAcceptedImageRights || isSaving}
loading={isSaving}
loadingText="Enregistrement..."
containerStyle={styles.continueButton}
/>
</ScrollView>
</Page>
)
}
export default PlaybackImageRightsConsent
const styles = StyleSheet.create({
container: {
flexGrow: 1,
width: '100%',
maxWidth: 620,
alignSelf: 'center',
paddingHorizontal: gutters,
paddingTop: gutters,
paddingBottom: gutters * 2,
gap: gutters,
},
disclaimerCard: {
padding: gutters,
borderRadius: 18,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.16)',
backgroundColor: 'rgba(5, 12, 24, 0.82)',
gap: 12,
},
disclaimerTitle: {
color: Palette.white,
fontFamily: FONT_FAMILY.InterSemiBold,
fontSize: 18,
},
disclaimerText: {
color: Palette.white,
fontFamily: FONT_FAMILY.InterRegular,
fontSize: 15,
lineHeight: 22,
opacity: 0.86,
},
consentContainer: {
padding: gutters,
borderRadius: 16,
backgroundColor: 'rgba(255, 255, 255, 0.08)',
},
continueButton: {
width: '100%',
maxWidth: 360,
alignSelf: 'center',
},
})
+40 -303
View File
@@ -25,13 +25,12 @@ import CreateLyricsHeader from '../Writing/components/CreateLyricsHeader'
const TIME_BEFORE_INCREMENT_MS = 20000 // 20s const TIME_BEFORE_INCREMENT_MS = 20000 // 20s
const COUNTDOWN_SECONDS = 10 const COUNTDOWN_SECONDS = 10
const CAMERA_STARTUP_DELAY_MS = 250 const CAMERA_STARTUP_DELAY_MS = 250
const KARAOKE_LEAD_S = 0.68
const LOG_PREFIX = '[RecordPlayback]' const LOG_PREFIX = '[RecordPlayback]'
const KEEP_AWAKE_TAG = 'record-playback' const KEEP_AWAKE_TAG = 'record-playback'
const log = const log =
typeof __DEV__ === 'undefined' || __DEV__ typeof __DEV__ === 'undefined' || __DEV__
? (...args) => console.log(LOG_PREFIX, ...args) ? (...args) => console.log(LOG_PREFIX, ...args)
: () => { } : () => {}
const CAMERA_FACING_OPTIONS = [ const CAMERA_FACING_OPTIONS = [
{ label: 'Avant', value: 'front' }, { label: 'Avant', value: 'front' },
@@ -53,7 +52,6 @@ const RecordPlayback = ({ route }) => {
const countdownTimerRef = useRef(null) const countdownTimerRef = useRef(null)
const listenTimerRef = useRef(null) const listenTimerRef = useRef(null)
const checkSongEndRef = useRef(null) const checkSongEndRef = useRef(null)
const isPausedRef = useRef(false)
const stopRequestedRef = useRef(false) const stopRequestedRef = useRef(false)
const restartRequestedRef = useRef(false) const restartRequestedRef = useRef(false)
const recordingStartTimeRef = useRef(0) // Track recording start time for duration check const recordingStartTimeRef = useRef(0) // Track recording start time for duration check
@@ -63,12 +61,8 @@ const RecordPlayback = ({ route }) => {
const exitRequestedRef = useRef(false) const exitRequestedRef = useRef(false)
const startedRef = useRef(false) // empêche les doubles démarrages const startedRef = useRef(false) // empêche les doubles démarrages
const countdownActiveRef = useRef(false) // évite le déclenchement avant 1er tick const countdownActiveRef = useRef(false) // évite le déclenchement avant 1er tick
const playbackStartedRef = useRef(false) // devient vrai lorsque l'audio progresse réellement const playbackStartedRef = useRef(false)
const playbackStartedAtRef = useRef(0) const playbackStartedAtRef = useRef(0)
const playbackProbeRef = useRef({ lastPos: 0, lastAt: 0 })
const playbackStartRequestAtRef = useRef(0)
const playbackStartWarnedRef = useRef(false)
const progressLogRef = useRef({ bucket: -1, lastPos: -1, lastDur: -1 })
const originalLoopingValueRef = useRef({ hasValue: false, value: false }) const originalLoopingValueRef = useRef({ hasValue: false, value: false })
const latestLoopingValueRef = useRef(isLooping ?? false) const latestLoopingValueRef = useRef(isLooping ?? false)
@@ -80,9 +74,7 @@ const RecordPlayback = ({ route }) => {
const [isPreparing, setIsPreparing] = useState(false) const [isPreparing, setIsPreparing] = useState(false)
const [countdown, setCountdown] = useState(0) const [countdown, setCountdown] = useState(0)
const [isRecording, setIsRecording] = useState(false) const [isRecording, setIsRecording] = useState(false)
const [isPaused, setIsPaused] = useState(false)
const [showProgress, setShowProgress] = useState(false) const [showProgress, setShowProgress] = useState(false)
const [progressInfo, setProgressInfo] = useState({ pos: 0, dur: 0 })
const [cameraFacing, setCameraFacing] = useState('front') const [cameraFacing, setCameraFacing] = useState('front')
// Musique // Musique
@@ -103,10 +95,6 @@ const RecordPlayback = ({ route }) => {
useEffect(() => { useEffect(() => {
latestLoopingValueRef.current = isLooping ?? false latestLoopingValueRef.current = isLooping ?? false
}, [isLooping]) }, [isLooping])
useEffect(() => {
isPausedRef.current = isPaused
}, [isPaused])
useEffect(() => { useEffect(() => {
const shouldKeepAwake = isPreparing || isRecording const shouldKeepAwake = isPreparing || isRecording
const updateKeepAwake = async () => { const updateKeepAwake = async () => {
@@ -116,13 +104,13 @@ const RecordPlayback = ({ route }) => {
} else { } else {
await deactivateKeepAwake(KEEP_AWAKE_TAG) await deactivateKeepAwake(KEEP_AWAKE_TAG)
} }
} catch (_) { } } catch (_) {}
} }
void updateKeepAwake() void updateKeepAwake()
return () => { return () => {
try { try {
void deactivateKeepAwake(KEEP_AWAKE_TAG) void deactivateKeepAwake(KEEP_AWAKE_TAG)
} catch (_) { } } catch (_) {}
} }
}, [isPreparing, isRecording]) }, [isPreparing, isRecording])
@@ -150,156 +138,23 @@ const RecordPlayback = ({ route }) => {
})) }))
}, [project?.musicTimestamps, musicIndex]) }, [project?.musicTimestamps, musicIndex])
// Build line groups to display line-by-line
const lines = useMemo(() => {
const out = []
let buf = []
let start = null
const clean = (txt) =>
String(txt || '')
.replace(/\s+/g, ' ')
.trim()
const isSentenceEnd = (txt) => /[.!?]$/.test((txt || '').trim())
const isSectionTag = (txt) => /^\s*\[[^\]]+\]\s*$/i.test((txt || '').trim())
for (let i = 0; i < alignedWords.length; i++) {
const w = alignedWords[i]
const original = String(w.word || '')
const textNoNewline = original.replace(/\n/g, ' ')
const gapToNext =
i < alignedWords.length - 1 ? Math.max(0, alignedWords[i + 1].startS - w.endS) : 0
// If buffer empty, mark start
if (buf.length === 0) start = w.startS
// Section tag becomes its own line
if (isSectionTag(textNoNewline)) {
const joined = clean(textNoNewline)
if (joined) out.push({ text: joined, startS: start ?? w.startS, endS: w.endS })
buf = []
start = null
continue
}
buf.push(textNoNewline)
const eolByNewline = /\n/.test(original)
const eolByPause = gapToNext >= 0.6
const eolByPunct = isSentenceEnd(textNoNewline)
const isLast = i === alignedWords.length - 1
if (eolByNewline || eolByPause || eolByPunct || isLast) {
const joined = clean(buf.join(' '))
if (joined) out.push({ text: joined, startS: start ?? w.startS, endS: w.endS })
buf = []
start = null
}
}
return out
}, [alignedWords])
useEffect(() => { useEffect(() => {
listenedMsRef.current = 0 listenedMsRef.current = 0
incrementDoneRef.current = false incrementDoneRef.current = false
progressLogRef.current = { bucket: -1, lastPos: -1, lastDur: -1 }
playbackStartedRef.current = false playbackStartedRef.current = false
playbackStartedAtRef.current = 0 playbackStartedAtRef.current = 0
playbackProbeRef.current = { lastPos: 0, lastAt: 0 }
playbackStartRequestAtRef.current = 0
playbackStartWarnedRef.current = false
setProgressInfo({ pos: 0, dur: 0 })
log('Song URL changed, reset counters', { songUrl }) log('Song URL changed, reset counters', { songUrl })
}, [songUrl]) }, [songUrl])
// Poll player -> progress ring // The audio player is the single clock for the progress ring and lyrics.
useEffect(() => { const positionMs = player?.positionMs || 0
if (!player) return const durationMs = player?.durationMs || 0
const id = setInterval(() => {
try {
const now = Date.now()
const dur = (player?.duration || 0) * 1000
const pos = (player?.currentTime || 0) * 1000
const lastProbe = playbackProbeRef.current
const advancing = pos > (lastProbe?.lastPos || 0) + 30
const playbackHasAdvanced = pos >= 80 || advancing
playbackProbeRef.current = { lastPos: pos, lastAt: now }
if (
playbackStartRequestAtRef.current &&
!playbackStartedRef.current &&
!playbackStartWarnedRef.current &&
now - playbackStartRequestAtRef.current > 1500
) {
playbackStartWarnedRef.current = true
log('Playback not started yet', {
playing: player?.playing,
pos,
dur,
})
}
if (!playbackStartedRef.current && playbackHasAdvanced) {
playbackStartedRef.current = true
const estimatedStartAt = pos > 0 ? now - pos : now
playbackStartedAtRef.current = estimatedStartAt
log('Playback detected', {
pos,
dur,
startedAt: estimatedStartAt,
reason: advancing ? 'advancing' : 'position',
})
}
setProgressInfo((prev) => {
const effectivePos = playbackStartedRef.current ? pos : 0
if (prev.pos === effectivePos && prev.dur === dur) return prev
return { pos: effectivePos, dur }
})
if (dur <= 0) {
if (progressLogRef.current.lastDur !== 0) {
progressLogRef.current = { bucket: -1, lastPos: pos, lastDur: 0 }
log('Progress waiting for duration', {
pos,
playing: player?.playing,
})
} else {
progressLogRef.current.lastPos = pos
}
return
}
const pct = Math.max(0, Math.min(1, pos / dur))
const bucket = Math.floor(pct * 10)
const { bucket: prevBucket } = progressLogRef.current
if (bucket !== prevBucket) {
log('Progress update', {
bucket,
pct: Number.isFinite(pct) ? Number(pct.toFixed(2)) : pct,
pos,
dur,
playing: player?.playing,
})
}
progressLogRef.current = { bucket, lastPos: pos, lastDur: dur }
} catch (_) { }
}, 250)
return () => clearInterval(id)
}, [player])
// Compute current line index from playback time
const currentTimeS = (progressInfo?.pos || 0) / 1000
const currentLineIdx = useMemo(() => {
if (!lines || lines.length === 0) return -1
for (let i = 0; i < lines.length; i++) {
const L = lines[i]
if (currentTimeS >= L.startS && currentTimeS <= L.endS) return i
}
if (currentTimeS > (lines[lines.length - 1]?.endS || 0)) return lines.length - 1
return -1
}, [lines, currentTimeS])
const getScrollY = () => (typeof window !== 'undefined' ? window.scrollY : 0) const getScrollY = () => (typeof window !== 'undefined' ? window.scrollY : 0)
// Permissions au mount + cleanup // Permissions au mount + cleanup
useEffect(() => { useEffect(() => {
; (async () => { ;(async () => {
try { try {
if (!cameraPermission?.granted) { if (!cameraPermission?.granted) {
log('Requesting camera permission on mount') log('Requesting camera permission on mount')
@@ -307,7 +162,7 @@ const RecordPlayback = ({ route }) => {
} else { } else {
log('Camera permission already granted on mount') log('Camera permission already granted on mount')
} }
} catch (_) { } } catch (_) {}
})() })()
return () => { return () => {
try { try {
@@ -318,7 +173,7 @@ const RecordPlayback = ({ route }) => {
listenTimerRef.current = null listenTimerRef.current = null
checkSongEndRef.current = null checkSongEndRef.current = null
log('Cleanup on unmount, cleared timers') log('Cleanup on unmount, cleared timers')
} catch (_) { } } catch (_) {}
} }
}, []) // eslint-disable-line react-hooks/exhaustive-deps }, []) // eslint-disable-line react-hooks/exhaustive-deps
@@ -355,30 +210,23 @@ const RecordPlayback = ({ route }) => {
incrementDoneRef.current = false incrementDoneRef.current = false
playbackStartedRef.current = false playbackStartedRef.current = false
playbackStartedAtRef.current = 0 playbackStartedAtRef.current = 0
playbackProbeRef.current = { lastPos: 0, lastAt: 0 }
playbackStartRequestAtRef.current = 0
playbackStartWarnedRef.current = false
setProgressInfo({ pos: 0, dur: 0 })
progressLogRef.current = { bucket: -1, lastPos: -1, lastDur: -1 }
log('Session reset') log('Session reset')
setIsPreparing(false) setIsPreparing(false)
setIsRecording(false) setIsRecording(false)
setIsPaused(false)
isPausedRef.current = false
setShowProgress(false) setShowProgress(false)
setCountdown(0) setCountdown(0)
try { try {
await cameraRef.current?.resumePreview?.() await cameraRef.current?.resumePreview?.()
} catch (_) { } } catch (_) {}
if (player) { if (player) {
try { try {
if (player.playing) await player.pause?.() if (player.playing) await player.pause?.()
await player.seekTo?.(0) await player.seekTo?.(0)
} catch (_) { } } catch (_) {}
} }
} catch (_) { } } catch (_) {}
}, },
[player] [player]
) )
@@ -456,8 +304,6 @@ const RecordPlayback = ({ route }) => {
preserveSongEndWatcher, preserveSongEndWatcher,
preserveStopRequest, preserveStopRequest,
}) })
setIsPaused(false)
isPausedRef.current = false
if (!preserveRestartFlag) { if (!preserveRestartFlag) {
restartRequestedRef.current = false restartRequestedRef.current = false
manualRestartInFlightRef.current = false manualRestartInFlightRef.current = false
@@ -504,23 +350,16 @@ const RecordPlayback = ({ route }) => {
try { try {
stopRequestedRef.current = false stopRequestedRef.current = false
stopRequestedAtRef.current = 0 stopRequestedAtRef.current = 0
recordingStartTimeRef.current = Date.now() // Start timer
listenedMsRef.current = 0 listenedMsRef.current = 0
incrementDoneRef.current = false incrementDoneRef.current = false
setIsRecording(true) setIsRecording(true)
setShowProgress(true) setShowProgress(true)
setIsPaused(false)
isPausedRef.current = false
try { try {
await cameraRef.current?.resumePreview?.() await cameraRef.current?.resumePreview?.()
} catch (_) { } } catch (_) {}
playbackStartedRef.current = false playbackStartedRef.current = false
playbackProbeRef.current = { lastPos: 0, lastAt: 0 } playbackStartedAtRef.current = 0
playbackStartRequestAtRef.current = 0
playbackStartWarnedRef.current = false
setProgressInfo({ pos: 0, dur: 0 })
progressLogRef.current = { bucket: -1, lastPos: -1, lastDur: -1 }
log('startRecordingWithMusic', { log('startRecordingWithMusic', {
hasPlayer: !!player, hasPlayer: !!player,
songUrl, songUrl,
@@ -586,6 +425,7 @@ const RecordPlayback = ({ route }) => {
throw new Error("L'enregistrement vidéo n'est pas supporté sur cet appareil.") throw new Error("L'enregistrement vidéo n'est pas supporté sur cet appareil.")
})() })()
recordingStartTimeRef.current = Date.now()
activeRecordingPromiseRef.current = recordPromise activeRecordingPromiseRef.current = recordPromise
if (player && songUrl) { if (player && songUrl) {
@@ -598,12 +438,14 @@ const RecordPlayback = ({ route }) => {
}) })
} }
// Add artificial delay to compensate for camera startup latency (Audio usually starts faster than video recording) // Give the native recorder a short warm-up, then measure the real gap
await new Promise(r => setTimeout(r, CAMERA_STARTUP_DELAY_MS)) // between the recording request and the audio start for the final trim.
await new Promise((resolve) => setTimeout(resolve, CAMERA_STARTUP_DELAY_MS))
try { try {
playbackStartRequestAtRef.current = Date.now()
await player.play?.() await player.play?.()
playbackStartedAtRef.current = Date.now()
playbackStartedRef.current = true
log('player.play invoked') log('player.play invoked')
} catch (error) { } catch (error) {
log('player.play failed', { log('player.play failed', {
@@ -625,10 +467,10 @@ const RecordPlayback = ({ route }) => {
try { try {
await projectsRef.doc(project.id).set({ views: increment(1) }, { merge: true }) await projectsRef.doc(project.id).set({ views: increment(1) }, { merge: true })
log('Views incremented') log('Views incremented')
} catch (_) { } } catch (_) {}
} }
} }
} catch (_) { } } catch (_) {}
}, 500) }, 500)
} }
@@ -638,7 +480,6 @@ const RecordPlayback = ({ route }) => {
checkSongEndRef.current = setInterval(() => { checkSongEndRef.current = setInterval(() => {
try { try {
if (!player) return if (!player) return
if (isPausedRef.current) return
if (stopRequestedRef.current) { if (stopRequestedRef.current) {
const sinceLastRequest = Date.now() - (stopRequestedAtRef.current || 0) const sinceLastRequest = Date.now() - (stopRequestedAtRef.current || 0)
if (sinceLastRequest >= 1200) { if (sinceLastRequest >= 1200) {
@@ -646,7 +487,7 @@ const RecordPlayback = ({ route }) => {
try { try {
cameraRef.current?.stopRecording?.() cameraRef.current?.stopRecording?.()
log('stopRecording retried while awaiting stop') log('stopRecording retried while awaiting stop')
} catch (_) { } } catch (_) {}
} }
return return
} }
@@ -671,9 +512,9 @@ const RecordPlayback = ({ route }) => {
try { try {
cameraRef.current?.stopRecording?.() cameraRef.current?.stopRecording?.()
log('stopRecording triggered') log('stopRecording triggered')
} catch (_) { } } catch (_) {}
} }
} catch (_) { } } catch (_) {}
}, 500) }, 500)
} }
@@ -691,7 +532,7 @@ const RecordPlayback = ({ route }) => {
} }
try { try {
if (player?.playing) await player.pause?.() if (player?.playing) await player.pause?.()
} catch (_) { } } catch (_) {}
if (listenTimerRef.current) { if (listenTimerRef.current) {
clearInterval(listenTimerRef.current) clearInterval(listenTimerRef.current)
listenTimerRef.current = null listenTimerRef.current = null
@@ -699,12 +540,9 @@ const RecordPlayback = ({ route }) => {
} }
setIsRecording(false) setIsRecording(false)
setIsPaused(false)
isPausedRef.current = false
setShowProgress(false) setShowProgress(false)
log('Recording flow completed', { hasVideo: !!video?.uri }) log('Recording flow completed', { hasVideo: !!video?.uri })
playbackStartedRef.current = false playbackStartedRef.current = false
progressLogRef.current = { bucket: -1, lastPos: -1, lastDur: -1 }
const exitRequested = exitRequestedRef.current const exitRequested = exitRequestedRef.current
const shouldRestart = restartRequestedRef.current const shouldRestart = restartRequestedRef.current
// Duration check logic // Duration check logic
@@ -724,7 +562,7 @@ const RecordPlayback = ({ route }) => {
await discardRecordingFile(video?.uri, 'too_short') await discardRecordingFile(video?.uri, 'too_short')
try { try {
goBack() goBack()
} catch (_) { } } catch (_) {}
return return
} }
} }
@@ -782,8 +620,6 @@ const RecordPlayback = ({ route }) => {
stopRequestedAtRef.current = 0 stopRequestedAtRef.current = 0
setIsRecording(false) setIsRecording(false)
setIsPreparing(false) setIsPreparing(false)
setIsPaused(false)
isPausedRef.current = false
setShowProgress(false) setShowProgress(false)
if (listenTimerRef.current) { if (listenTimerRef.current) {
clearInterval(listenTimerRef.current) clearInterval(listenTimerRef.current)
@@ -796,7 +632,6 @@ const RecordPlayback = ({ route }) => {
log('checkSongEndRef cleared (error path)') log('checkSongEndRef cleared (error path)')
} }
playbackStartedRef.current = false playbackStartedRef.current = false
progressLogRef.current = { bucket: -1, lastPos: -1, lastDur: -1 }
const exitRequested = exitRequestedRef.current const exitRequested = exitRequestedRef.current
const shouldRestart = restartRequestedRef.current const shouldRestart = restartRequestedRef.current
if (exitRequested) { if (exitRequested) {
@@ -832,7 +667,7 @@ const RecordPlayback = ({ route }) => {
onPress: () => { onPress: () => {
try { try {
goBack() goBack()
} catch (_) { } } catch (_) {}
}, },
}, },
], ],
@@ -859,21 +694,21 @@ const RecordPlayback = ({ route }) => {
if (player?.playing) { if (player?.playing) {
const maybePromise = player.pause?.() const maybePromise = player.pause?.()
if (maybePromise && typeof maybePromise.catch === 'function') { if (maybePromise && typeof maybePromise.catch === 'function') {
maybePromise.catch(() => { }) maybePromise.catch(() => {})
} }
} }
} catch (_) { } } catch (_) {}
try { try {
if (isRecording) { if (isRecording) {
cameraRef.current?.stopRecording?.() cameraRef.current?.stopRecording?.()
} }
} catch (_) { } } catch (_) {}
} else { } else {
exitRequestedRef.current = false exitRequestedRef.current = false
} }
try { try {
goBack() goBack()
} catch (_) { } } catch (_) {}
return true return true
}, [goBack, isPreparing, isRecording, player]) }, [goBack, isPreparing, isRecording, player])
@@ -886,33 +721,6 @@ const RecordPlayback = ({ route }) => {
}, [handleBackPress]) }, [handleBackPress])
) )
const handleTogglePause = useCallback(async () => {
try {
if (!isRecording) return
if (!isPausedRef.current) {
setIsPaused(true)
isPausedRef.current = true
stopRequestedRef.current = false
stopRequestedAtRef.current = 0
try {
await player?.pause?.()
} catch (_) { }
try {
await cameraRef.current?.pausePreview?.()
} catch (_) { }
return
}
setIsPaused(false)
isPausedRef.current = false
try {
await cameraRef.current?.resumePreview?.()
} catch (_) { }
try {
await player?.play?.()
} catch (_) { }
} catch (_) { }
}, [isRecording, player])
const handleRestartRecording = async () => { const handleRestartRecording = async () => {
try { try {
const hasRecordingPending = !!activeRecordingPromiseRef.current const hasRecordingPending = !!activeRecordingPromiseRef.current
@@ -921,11 +729,9 @@ const RecordPlayback = ({ route }) => {
isRecording, isRecording,
hasRecordingPending, hasRecordingPending,
}) })
setIsPaused(false)
isPausedRef.current = false
try { try {
await cameraRef.current?.resumePreview?.() await cameraRef.current?.resumePreview?.()
} catch (_) { } } catch (_) {}
if (isPreparing) { if (isPreparing) {
if (hasRecordingPending) { if (hasRecordingPending) {
await startCountdownThenRecord({ await startCountdownThenRecord({
@@ -952,16 +758,16 @@ const RecordPlayback = ({ route }) => {
stopRequestedAtRef.current = Date.now() stopRequestedAtRef.current = Date.now()
try { try {
if (player?.playing) await player.pause?.() if (player?.playing) await player.pause?.()
} catch (_) { } } catch (_) {}
try { try {
cameraRef.current?.stopRecording?.() cameraRef.current?.stopRecording?.()
} catch (_) { } } catch (_) {}
await startCountdownThenRecord({ await startCountdownThenRecord({
preserveRestartFlag: true, preserveRestartFlag: true,
preserveSongEndWatcher: true, preserveSongEndWatcher: true,
preserveStopRequest: true, preserveStopRequest: true,
}) })
} catch (_) { } } catch (_) {}
} }
const permissionsGranted = !!cameraPermission?.granted const permissionsGranted = !!cameraPermission?.granted
@@ -1033,7 +839,7 @@ const RecordPlayback = ({ route }) => {
onPress={async () => { onPress={async () => {
try { try {
if (!cameraPermission?.granted) await requestCameraPermission() if (!cameraPermission?.granted) await requestCameraPermission()
} catch (_) { } } catch (_) {}
}} }}
/> />
</View> </View>
@@ -1074,9 +880,7 @@ const RecordPlayback = ({ route }) => {
<ProgressRing <ProgressRing
size={120} size={120}
strokeWidth={8} strokeWidth={8}
progress={ progress={durationMs ? Math.min(1, positionMs / durationMs) : 0}
progressInfo.dur ? Math.min(1, (progressInfo.pos || 0) / progressInfo.dur) : 0
}
/> />
<TouchableOpacity <TouchableOpacity
activeOpacity={0.9} activeOpacity={0.9}
@@ -1099,34 +903,6 @@ const RecordPlayback = ({ route }) => {
<RestartSpinnerIcon /> <RestartSpinnerIcon />
</TouchableOpacity> </TouchableOpacity>
</View> </View>
{isRecording && (
<TouchableOpacity
activeOpacity={0.9}
onPress={handleTogglePause}
style={{
marginTop: 14,
paddingVertical: 10,
paddingHorizontal: 18,
borderRadius: 999,
backgroundColor: Palette.white,
shadowColor: '#000000',
shadowOpacity: 0.12,
shadowRadius: 10,
shadowOffset: { width: 0, height: 4 },
elevation: 4,
}}
>
<Text
style={{
color: Palette.black,
fontFamily: FONT_FAMILY.InterSemiBold,
fontSize: 14,
}}
>
{isPaused ? 'Reprendre' : 'Mettre en pause'}
</Text>
</TouchableOpacity>
)}
</View> </View>
)} )}
</View> </View>
@@ -1146,8 +922,7 @@ const RecordPlayback = ({ route }) => {
{(isRecording || showProgress) && alignedWords?.length > 0 ? ( {(isRecording || showProgress) && alignedWords?.length > 0 ? (
<KaraokeLyrics <KaraokeLyrics
alignedWords={alignedWords} alignedWords={alignedWords}
currentTimeS={(progressInfo?.pos || 0) / 1000} currentTimeS={positionMs / 1000}
timeOffsetS={KARAOKE_LEAD_S}
mode="teleprompter" mode="teleprompter"
teleprompterLines={6} teleprompterLines={6}
teleprompterAnchorLine={1} teleprompterAnchorLine={1}
@@ -1202,44 +977,6 @@ const ProgressRing = ({ size = 50, strokeWidth = 12, progress = 0 }) => {
export default RecordPlayback export default RecordPlayback
// Render previous/current/next line to reduce jumpiness
const KaraokeLines = ({ lines = [], currentLineIdx = -1 }) => {
const prev = currentLineIdx > 0 ? lines[currentLineIdx - 1]?.text : ''
const curr = currentLineIdx >= 0 ? lines[currentLineIdx]?.text : ''
const next = currentLineIdx + 1 < lines.length ? lines[currentLineIdx + 1]?.text : ''
return (
<View style={{ gap: 4 }}>
{/* {prev ? (
<Text style={{ color: "#FFFFFF99", fontSize: 14, textAlign: "center", fontFamily: FONT_FAMILY.InterMedium }}>
{prev}
</Text>
) : null} */}
<Text
style={{
color: Palette.white,
fontSize: 18,
textAlign: 'center',
fontFamily: FONT_FAMILY.InterSemiBold,
}}
>
{curr}
</Text>
{next ? (
<Text
style={{
color: '#FFFFFF99',
fontSize: 14,
textAlign: 'center',
fontFamily: FONT_FAMILY.InterMedium,
}}
>
{next}
</Text>
) : null}
</View>
)
}
const CameraFacingSelector = ({ value = 'front', onChange, disabled = false }) => { const CameraFacingSelector = ({ value = 'front', onChange, disabled = false }) => {
return ( return (
<View <View
+62 -210
View File
@@ -29,14 +29,6 @@ const WEB_PREVIEW_WIDTH = 360
const MEDIA_BOOTSTRAP_DELAY_MS = 250 const MEDIA_BOOTSTRAP_DELAY_MS = 250
const MEDIA_RETRY_DELAY_MS = 700 const MEDIA_RETRY_DELAY_MS = 700
const MEDIA_MAX_RETRIES = 2 const MEDIA_MAX_RETRIES = 2
const CAMERA_STARTUP_DELAY_MS = 250
const KARAOKE_LEAD_S = 0.68
const toSeconds = (v) => {
const n = Number(v ?? 0)
if (!Number.isFinite(n) || n < 0) return 0
return n > 10000 ? n / 1000 : n // heuristique ms → s
}
const pickRecorderMimeType = () => { const pickRecorderMimeType = () => {
if (typeof window === 'undefined' || typeof MediaRecorder === 'undefined') return undefined if (typeof window === 'undefined' || typeof MediaRecorder === 'undefined') return undefined
@@ -51,7 +43,7 @@ const pickRecorderMimeType = () => {
for (const mimeType of candidates) { for (const mimeType of candidates) {
try { try {
if (MediaRecorder.isTypeSupported(mimeType)) return mimeType if (MediaRecorder.isTypeSupported(mimeType)) return mimeType
} catch { } } catch {}
} }
return undefined return undefined
@@ -74,6 +66,8 @@ const RecordPlayback = ({ route }) => {
coverUrl: project?.coverUrl || null, coverUrl: project?.coverUrl || null,
metadata: { projectId: project?.id, screen: 'RecordPlayback' }, metadata: { projectId: project?.id, screen: 'RecordPlayback' },
}) })
const positionMs = player?.positionMs || 0
const durationMs = player?.durationMs || 0
// MediaStream / Recorder (web only) // MediaStream / Recorder (web only)
const previewVideoRef = useRef(null) const previewVideoRef = useRef(null)
@@ -96,31 +90,19 @@ const RecordPlayback = ({ route }) => {
const [countdown, setCountdown] = useState(0) const [countdown, setCountdown] = useState(0)
const [isPreparing, setIsPreparing] = useState(false) const [isPreparing, setIsPreparing] = useState(false)
const [isRecording, setIsRecording] = useState(false) const [isRecording, setIsRecording] = useState(false)
const [isPaused, setIsPaused] = useState(false)
const [pos, setPos] = useState(0)
const [dur, setDur] = useState(0)
// timers / refs // timers / refs
const isPausedRef = useRef(false)
const progressTimerRef = useRef(null)
const listenTimerRef = useRef(null) const listenTimerRef = useRef(null)
const countdownTimerRef = useRef(null) const countdownTimerRef = useRef(null)
const perfStartRef = useRef(null)
const pausedAtRef = useRef(null)
const pausedMsRef = useRef(0)
const listenedMsRef = useRef(0) const listenedMsRef = useRef(0)
const viewsIncrementedRef = useRef(false) const viewsIncrementedRef = useRef(false)
const correctedInitialJumpRef = useRef(false)
const playbackStartedAtRef = useRef(null) const playbackStartedAtRef = useRef(null)
const recordingStartAtRef = useRef(null) const recordingStartAtRef = useRef(null)
const stopInFlightRef = useRef(false)
useEffect(() => { useEffect(() => {
latestLoopingValueRef.current = isLooping ?? false latestLoopingValueRef.current = isLooping ?? false
}, [isLooping]) }, [isLooping])
useEffect(() => {
isPausedRef.current = isPaused
}, [isPaused])
// Lyrics // Lyrics
const alignedWords = useMemo(() => { const alignedWords = useMemo(() => {
const ts = project?.musicTimestamps?.[songIndex] const ts = project?.musicTimestamps?.[songIndex]
@@ -134,10 +116,9 @@ const RecordPlayback = ({ route }) => {
}, [project?.musicTimestamps, songIndex]) }, [project?.musicTimestamps, songIndex])
const clearAllTimers = () => { const clearAllTimers = () => {
if (progressTimerRef.current) clearInterval(progressTimerRef.current)
if (listenTimerRef.current) clearInterval(listenTimerRef.current) if (listenTimerRef.current) clearInterval(listenTimerRef.current)
if (countdownTimerRef.current) clearInterval(countdownTimerRef.current) if (countdownTimerRef.current) clearInterval(countdownTimerRef.current)
progressTimerRef.current = listenTimerRef.current = countdownTimerRef.current = null listenTimerRef.current = countdownTimerRef.current = null
} }
const clearMediaRetry = useCallback(() => { const clearMediaRetry = useCallback(() => {
@@ -150,19 +131,12 @@ const RecordPlayback = ({ route }) => {
const resetUI = () => { const resetUI = () => {
setIsPreparing(false) setIsPreparing(false)
setIsRecording(false) setIsRecording(false)
setIsPaused(false)
isPausedRef.current = false
pausedAtRef.current = null
pausedMsRef.current = 0
setCountdown(0) setCountdown(0)
setPos(0)
setDur(0)
listenedMsRef.current = 0 listenedMsRef.current = 0
viewsIncrementedRef.current = false viewsIncrementedRef.current = false
perfStartRef.current = null
correctedInitialJumpRef.current = false
playbackStartedAtRef.current = null playbackStartedAtRef.current = null
recordingStartAtRef.current = null recordingStartAtRef.current = null
stopInFlightRef.current = false
} }
const releaseRecordingUrl = useCallback(() => { const releaseRecordingUrl = useCallback(() => {
@@ -191,14 +165,14 @@ const RecordPlayback = ({ route }) => {
if (video.srcObject !== stream) { if (video.srcObject !== stream) {
video.srcObject = stream video.srcObject = stream
} }
} catch (e) { } } catch (e) {}
try { try {
const playPromise = video.play?.() const playPromise = video.play?.()
if (playPromise && typeof playPromise.catch === 'function') { if (playPromise && typeof playPromise.catch === 'function') {
playPromise.catch(() => { }) playPromise.catch(() => {})
} }
} catch (e) { } } catch (e) {}
return true return true
}, []) }, [])
@@ -271,16 +245,16 @@ const RecordPlayback = ({ route }) => {
[clearMediaRetry, ensureMediaStream] [clearMediaRetry, ensureMediaStream]
) )
const startRecorder = useCallback(() => { const startRecorder = useCallback(async () => {
if (!mediaStreamRef.current) { if (!mediaStreamRef.current) {
return false return null
} }
try { try {
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') { if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
mediaRecorderRef.current.stop() mediaRecorderRef.current.stop()
} }
} catch (e) { } } catch (e) {}
recordedChunksRef.current = [] recordedChunksRef.current = []
releaseRecordingUrl() releaseRecordingUrl()
@@ -294,7 +268,7 @@ const RecordPlayback = ({ route }) => {
: new MediaRecorder(mediaStreamRef.current) : new MediaRecorder(mediaStreamRef.current)
} catch (e) { } catch (e) {
setMediaError(e instanceof Error ? e : new Error(String(e || ''))) setMediaError(e instanceof Error ? e : new Error(String(e || '')))
return false return null
} }
mediaRecorderRef.current = recorder mediaRecorderRef.current = recorder
@@ -308,9 +282,21 @@ const RecordPlayback = ({ route }) => {
} }
} }
let resolveRecorderStart
const recorderStartedPromise = new Promise((resolve) => {
resolveRecorderStart = resolve
})
recorder.onstart = () => {
resolveRecorderStart?.(performance.now())
resolveRecorderStart = null
}
recorder.onerror = (event) => { recorder.onerror = (event) => {
const err = event?.error || event const err = event?.error || event
setMediaError(err instanceof Error ? err : new Error(String(err || ''))) setMediaError(err instanceof Error ? err : new Error(String(err || '')))
resolveRecorderStart?.(null)
resolveRecorderStart = null
} }
recorder.onstop = () => { recorder.onstop = () => {
@@ -340,8 +326,10 @@ const RecordPlayback = ({ route }) => {
try { try {
recorder.start(1000) recorder.start(1000)
return true return await recorderStartedPromise
} catch (e) { } catch (e) {
resolveRecorderStart?.(null)
resolveRecorderStart = null
if (stopRecordingResolveRef.current) { if (stopRecordingResolveRef.current) {
stopRecordingResolveRef.current(null) stopRecordingResolveRef.current(null)
stopRecordingResolveRef.current = null stopRecordingResolveRef.current = null
@@ -349,7 +337,7 @@ const RecordPlayback = ({ route }) => {
stopRecordingPromiseRef.current = null stopRecordingPromiseRef.current = null
mediaRecorderRef.current = null mediaRecorderRef.current = null
setMediaError(e instanceof Error ? e : new Error(String(e || ''))) setMediaError(e instanceof Error ? e : new Error(String(e || '')))
return false return null
} }
}, [releaseRecordingUrl, setMediaError]) }, [releaseRecordingUrl, setMediaError])
@@ -366,7 +354,7 @@ const RecordPlayback = ({ route }) => {
} }
recorder.stop() recorder.stop()
} }
} catch (e) { } } catch (e) {}
if (!waitPromise) { if (!waitPromise) {
return recordedUrlRef.current || null return recordedUrlRef.current || null
@@ -390,10 +378,10 @@ const RecordPlayback = ({ route }) => {
try { try {
await player?.pause?.() await player?.pause?.()
await player?.seekTo?.(0) await player?.seekTo?.(0)
} catch (e) { } } catch (e) {}
try { try {
await stopRecorderAndGetUrl() await stopRecorderAndGetUrl()
} catch (e) { } } catch (e) {}
if (!preserveRecordingForNextScreenRef.current) { if (!preserveRecordingForNextScreenRef.current) {
releaseRecordingUrl() releaseRecordingUrl()
} }
@@ -433,12 +421,12 @@ const RecordPlayback = ({ route }) => {
) )
useEffect(() => { useEffect(() => {
; (async () => { ;(async () => {
try { try {
if (!cameraPermission?.granted) { if (!cameraPermission?.granted) {
await requestCameraPermission() await requestCameraPermission()
} }
} catch (e) { } } catch (e) {}
})() })()
return () => { return () => {
clearAllTimers() clearAllTimers()
@@ -481,7 +469,7 @@ const RecordPlayback = ({ route }) => {
} else { } else {
try { try {
video.srcObject = null video.srcObject = null
} catch { } } catch {}
} }
}, [attachPreview, mediaReady, mediaError]) }, [attachPreview, mediaReady, mediaError])
@@ -513,7 +501,7 @@ const RecordPlayback = ({ route }) => {
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') { if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
mediaRecorderRef.current.stop() mediaRecorderRef.current.stop()
} }
} catch { } } catch {}
stopMediaStream() stopMediaStream()
clearMediaRetry() clearMediaRetry()
if (!preserveRecordingForNextScreenRef.current) { if (!preserveRecordingForNextScreenRef.current) {
@@ -523,11 +511,13 @@ const RecordPlayback = ({ route }) => {
}, [clearMediaRetry, releaseRecordingUrl, preserveRecordingForNextScreenRef, stopMediaStream]) }, [clearMediaRetry, releaseRecordingUrl, preserveRecordingForNextScreenRef, stopMediaStream])
const stopAndNavigate = useCallback(async () => { const stopAndNavigate = useCallback(async () => {
if (stopInFlightRef.current) return
stopInFlightRef.current = true
preserveRecordingForNextScreenRef.current = true preserveRecordingForNextScreenRef.current = true
clearAllTimers() clearAllTimers()
try { try {
await player?.pause?.() await player?.pause?.()
} catch (e) { } } catch (e) {}
let videoUrl = null let videoUrl = null
try { try {
@@ -535,13 +525,9 @@ const RecordPlayback = ({ route }) => {
if (!videoUrl) { if (!videoUrl) {
videoUrl = recordedUrlRef.current || null videoUrl = recordedUrlRef.current || null
} }
} catch (e) { } } catch (e) {}
setIsRecording(false) setIsRecording(false)
setIsPaused(false)
isPausedRef.current = false
pausedAtRef.current = null
pausedMsRef.current = 0
const rawOffsetMs = const rawOffsetMs =
playbackStartedAtRef.current != null && recordingStartAtRef.current != null playbackStartedAtRef.current != null && recordingStartAtRef.current != null
@@ -556,68 +542,10 @@ const RecordPlayback = ({ route }) => {
}) })
}, [player, project, stopRecorderAndGetUrl]) }, [player, project, stopRecorderAndGetUrl])
const startProgressLoop = useCallback(() => { useEffect(() => {
if (progressTimerRef.current) clearInterval(progressTimerRef.current) if (!isRecording || !durationMs || positionMs < durationMs - 300) return
progressTimerRef.current = setInterval(async () => {
const rawDur = toSeconds(player?.duration)
const rawPos = toSeconds(player?.currentTime)
const pausedSince =
pausedAtRef.current != null ? Math.max(0, performance.now() - pausedAtRef.current) : 0
const pausedTotal = pausedMsRef.current + pausedSince
// fallback monotone si le player ne donne rien
const fallback =
perfStartRef.current != null
? Math.max(0, (performance.now() - perfStartRef.current - pausedTotal) / 1000)
: 0
let nextDur = rawDur > 0 ? rawDur : dur || 0
let nextPos = Number.isFinite(rawPos) && rawPos >= 0 ? rawPos : fallback > 0 ? fallback : 0
// 🔧 Correction 1-shot du "jump initial" (ex: 0 → 93s au 1er tick)
if (!correctedInitialJumpRef.current && nextPos > 1 && fallback < 1.5) {
try {
await player?.seekTo?.(0)
correctedInitialJumpRef.current = true
nextPos = 0
} catch (e) { }
}
const timeSinceStart =
perfStartRef.current != null
? Math.max(0, performance.now() - perfStartRef.current - pausedTotal)
: null
const shouldTrustFallback =
fallback > 0 &&
((nextDur > 0 && nextPos > nextDur + 0.5) ||
nextPos - fallback > 1.5 ||
(timeSinceStart != null && timeSinceStart < 5000 && nextPos > fallback + 1))
if (shouldTrustFallback) {
nextPos = fallback
}
if (nextDur > 0 && nextPos > nextDur) nextPos = nextDur
setDur(nextDur)
setPos(nextPos)
if (nextPos > 0 && playbackStartedAtRef.current == null && perfStartRef.current != null) {
playbackStartedAtRef.current =
perfStartRef.current + Math.max(0, (nextPos * 1000) - pausedTotal)
}
if (isPausedRef.current) {
return
}
if (nextDur > 0 && nextPos >= nextDur - 0.3) {
void stopAndNavigate() void stopAndNavigate()
} }, [durationMs, isRecording, positionMs, stopAndNavigate])
}, 250)
}, [player, stopAndNavigate])
const startListenLoop = useCallback(() => { const startListenLoop = useCallback(() => {
if (listenTimerRef.current) clearInterval(listenTimerRef.current) if (listenTimerRef.current) clearInterval(listenTimerRef.current)
@@ -630,39 +558,31 @@ const RecordPlayback = ({ route }) => {
if (project?.id) { if (project?.id) {
try { try {
await projectsRef.doc(project.id).set({ views: increment(1) }, { merge: true }) await projectsRef.doc(project.id).set({ views: increment(1) }, { merge: true })
} catch (e) { } } catch (e) {}
} }
} }
} }
} catch (e) { } } catch (e) {}
}, 500) }, 500)
}, [player, project?.id]) }, [player, project?.id])
const startPlayback = useCallback(async () => { const startPlayback = useCallback(async () => {
try { try {
setIsPaused(false) await player?.pause?.()
isPausedRef.current = false await player?.seekTo?.(0)
pausedAtRef.current = null const recordingStartedAt = await startRecorder()
pausedMsRef.current = 0 if (recordingStartedAt == null) {
await player?.pause?.() // s'assure qu'on repart propre throw new Error("Impossible de démarrer l'enregistrement vidéo")
await player?.seekTo?.(0) // tente un seek d'amorçage
recordingStartAtRef.current = performance.now()
const recorderStarted = startRecorder()
if (!recorderStarted) {
} }
await new Promise(r => setTimeout(r, CAMERA_STARTUP_DELAY_MS)) recordingStartAtRef.current = recordingStartedAt
await player?.play?.() // attend la promesse → l'élément est prêt await player?.play?.()
perfStartRef.current = perfStartRef.current ?? performance.now() playbackStartedAtRef.current = performance.now()
correctedInitialJumpRef.current = false // autorise la correction 1-shot
setIsRecording(true) setIsRecording(true)
startProgressLoop()
startListenLoop() startListenLoop()
} catch (e) { } catch (e) {
setIsRecording(false) setIsRecording(false)
setIsPreparing(false) setIsPreparing(false)
setIsPaused(false) stopInFlightRef.current = false
isPausedRef.current = false
pausedAtRef.current = null
const msg = String(e?.message || e || '') const msg = String(e?.message || e || '')
if (/not supported on the simulator/i.test(msg)) { if (/not supported on the simulator/i.test(msg)) {
alert( alert(
@@ -674,7 +594,7 @@ const RecordPlayback = ({ route }) => {
onPress: () => { onPress: () => {
try { try {
goBack() goBack()
} catch { } } catch {}
}, },
}, },
], ],
@@ -682,7 +602,7 @@ const RecordPlayback = ({ route }) => {
) )
} }
} }
}, [player, startRecorder, startProgressLoop, startListenLoop]) }, [player, startRecorder, startListenLoop])
const permissionsGranted = !!cameraPermission?.granted const permissionsGranted = !!cameraPermission?.granted
const canRecord = permissionsGranted && mediaReady && !mediaError const canRecord = permissionsGranted && mediaReady && !mediaError
@@ -690,8 +610,6 @@ const RecordPlayback = ({ route }) => {
const startCountdownThenRecord = useCallback(async () => { const startCountdownThenRecord = useCallback(async () => {
if (!songUrl || !canRecord) return if (!songUrl || !canRecord) return
await resetSession() await resetSession()
setIsPaused(false)
isPausedRef.current = false
setIsPreparing(true) setIsPreparing(true)
setCountdown(COUNTDOWN_SECONDS) setCountdown(COUNTDOWN_SECONDS)
if (countdownTimerRef.current) clearInterval(countdownTimerRef.current) if (countdownTimerRef.current) clearInterval(countdownTimerRef.current)
@@ -710,50 +628,13 @@ const RecordPlayback = ({ route }) => {
}, 1000) }, 1000)
}, [songUrl, canRecord, resetSession, startPlayback]) }, [songUrl, canRecord, resetSession, startPlayback])
const handleTogglePause = useCallback(async () => {
try {
if (!isRecording) return
if (!isPausedRef.current) {
isPausedRef.current = true
setIsPaused(true)
pausedAtRef.current = performance.now()
try {
await player?.pause?.()
} catch (e) { }
try {
const recorder = mediaRecorderRef.current
if (recorder && recorder.state === 'recording') {
recorder.pause?.()
}
} catch (e) { }
return
}
const pausedAt = pausedAtRef.current
if (pausedAt != null) {
pausedMsRef.current += performance.now() - pausedAt
pausedAtRef.current = null
}
isPausedRef.current = false
setIsPaused(false)
try {
const recorder = mediaRecorderRef.current
if (recorder && recorder.state === 'paused') {
recorder.resume?.()
}
} catch (e) { }
try {
await player?.play?.()
} catch (e) { }
} catch (e) { }
}, [isRecording, player])
const handleRestartRecording = useCallback(async () => { const handleRestartRecording = useCallback(async () => {
try { try {
await startCountdownThenRecord() await startCountdownThenRecord()
} catch (e) { } } catch (e) {}
}, [startCountdownThenRecord]) }, [startCountdownThenRecord])
const progressRatio = dur > 0 ? Math.min(1, pos / dur) : 0 const progressRatio = durationMs > 0 ? Math.min(1, positionMs / durationMs) : 0
return ( return (
<Page <Page
@@ -793,9 +674,9 @@ const RecordPlayback = ({ route }) => {
const video = event?.currentTarget const video = event?.currentTarget
const playPromise = video?.play?.() const playPromise = video?.play?.()
if (playPromise && typeof playPromise.catch === 'function') { if (playPromise && typeof playPromise.catch === 'function') {
playPromise.catch(() => { }) playPromise.catch(() => {})
} }
} catch { } } catch {}
}} }}
/> />
@@ -941,34 +822,6 @@ const RecordPlayback = ({ route }) => {
<RestartSpinnerIcon /> <RestartSpinnerIcon />
</TouchableOpacity> </TouchableOpacity>
</View> </View>
{isRecording && (
<TouchableOpacity
activeOpacity={0.9}
onPress={handleTogglePause}
style={{
marginTop: 14,
paddingVertical: 10,
paddingHorizontal: 18,
borderRadius: 999,
backgroundColor: Palette.white,
shadowColor: '#000000',
shadowOpacity: 0.12,
shadowRadius: 10,
shadowOffset: { width: 0, height: 4 },
elevation: 4,
}}
>
<Text
style={{
color: Palette.black,
fontFamily: FONT_FAMILY.InterSemiBold,
fontSize: 14,
}}
>
{isPaused ? 'Reprendre' : 'Mettre en pause'}
</Text>
</TouchableOpacity>
)}
</View> </View>
)} )}
@@ -988,7 +841,7 @@ const RecordPlayback = ({ route }) => {
onPress={async () => { onPress={async () => {
try { try {
if (!cameraPermission?.granted) await requestCameraPermission() if (!cameraPermission?.granted) await requestCameraPermission()
} catch { } } catch {}
}} }}
/> />
</View> </View>
@@ -1010,8 +863,7 @@ const RecordPlayback = ({ route }) => {
{isRecording && alignedWords.length > 0 ? ( {isRecording && alignedWords.length > 0 ? (
<KaraokeLyrics <KaraokeLyrics
alignedWords={alignedWords} alignedWords={alignedWords}
currentTimeS={pos} currentTimeS={positionMs / 1000}
timeOffsetS={KARAOKE_LEAD_S}
mode="teleprompter" mode="teleprompter"
teleprompterLines={6} teleprompterLines={6}
teleprompterAnchorLine={1} teleprompterAnchorLine={1}
+29 -25
View File
@@ -79,6 +79,20 @@ const RecordedPlayback = ({ route }) => {
[audioPlayer, videoPlayer] [audioPlayer, videoPlayer]
) )
const syncVideoToAudio = useCallback(() => {
if (!videoPlayer) return
const rawAudioMs = getCurrentRawAudioPositionMs()
const timelineMs = getTimelinePositionMs(rawAudioMs, syncOffsetRef.current)
const expectedVideoMs = getVideoPositionMs(timelineMs, syncOffsetRef.current)
videoPlayer.currentTime = expectedVideoMs / 1000
}, [getCurrentRawAudioPositionMs, videoPlayer])
const playSynchronized = useCallback(async () => {
if (audioPlayer && songUrl) await audioPlayer.play?.()
if (videoPlayer) videoPlayer.play()
syncVideoToAudio()
}, [audioPlayer, songUrl, syncVideoToAudio, videoPlayer])
const stopPlayback = useCallback(async () => { const stopPlayback = useCallback(async () => {
try { try {
if (audioPlayer) await audioPlayer.pause?.() if (audioPlayer) await audioPlayer.pause?.()
@@ -104,8 +118,7 @@ const RecordedPlayback = ({ route }) => {
const start = async () => { const start = async () => {
try { try {
await alignPlaybackToTimeline(0, initialSyncOffsetMs) await alignPlaybackToTimeline(0, initialSyncOffsetMs)
if (audioPlayer && songUrl) await audioPlayer.play?.() await playSynchronized()
if (videoPlayer) videoPlayer.play()
} catch (e) {} } catch (e) {}
} }
@@ -115,7 +128,7 @@ const RecordedPlayback = ({ route }) => {
playbackEndedRef.current = false playbackEndedRef.current = false
void stopPlayback() void stopPlayback()
} }
}, [alignPlaybackToTimeline, audioPlayer, initialSyncOffsetMs, songUrl, stopPlayback, videoPlayer]) }, [alignPlaybackToTimeline, initialSyncOffsetMs, playSynchronized, stopPlayback])
useEffect(() => { useEffect(() => {
const id = global.setInterval(() => { const id = global.setInterval(() => {
@@ -139,7 +152,7 @@ const RecordedPlayback = ({ route }) => {
const currentVideoMs = (videoPlayer.currentTime || 0) * 1000 const currentVideoMs = (videoPlayer.currentTime || 0) * 1000
const driftMs = Math.abs(currentVideoMs - expectedVideoMs) const driftMs = Math.abs(currentVideoMs - expectedVideoMs)
if (driftMs > 350) { if (driftMs > 120) {
videoPlayer.currentTime = Math.max(0, expectedVideoMs / 1000) videoPlayer.currentTime = Math.max(0, expectedVideoMs / 1000)
} }
} }
@@ -175,16 +188,9 @@ const RecordedPlayback = ({ route }) => {
const resumeAfterSeek = useCallback(async () => { const resumeAfterSeek = useCallback(async () => {
try { try {
if (audioPlayer?.resume) { await playSynchronized()
await audioPlayer.resume?.()
} else if (audioPlayer) {
await audioPlayer.play?.()
}
} catch (e) {} } catch (e) {}
try { }, [playSynchronized])
if (videoPlayer) videoPlayer.play()
} catch (e) {}
}, [audioPlayer, videoPlayer])
const handleSyncOffsetChange = useCallback( const handleSyncOffsetChange = useCallback(
(nextOffsetMs) => { (nextOffsetMs) => {
@@ -261,23 +267,14 @@ const RecordedPlayback = ({ route }) => {
playbackEndedRef.current = false playbackEndedRef.current = false
if (songUrl && audioPlayer) { await playSynchronized()
if (audioPlayer?.resume) {
await audioPlayer.resume?.()
} else {
await audioPlayer.play?.()
}
}
if (videoPlayer) videoPlayer.play()
} catch (e) {} } catch (e) {}
}, [alignPlaybackToTimeline, audioPlayer, progressInfo, songUrl, videoPlayer]) }, [alignPlaybackToTimeline, audioPlayer, playSynchronized, progressInfo, videoPlayer])
return ( return (
<Page backgroundColor={Palette.grayMid} headerType="NONE"> <Page backgroundColor={Palette.grayMid} headerType="NONE">
<MusicLandHeader progress={19} onPressBack={goBack} /> <MusicLandHeader progress={19} onPressBack={goBack} />
<View style={{ flex: 1, paddingBottom: gutters * 2 }}> <View style={{ flex: 1, paddingBottom: gutters * 2 }}>
{/* Vidéo avec play/pause en overlay */} {/* Vidéo avec play/pause en overlay */}
<Pressable <Pressable
onPress={handleTogglePlayback} onPress={handleTogglePlayback}
@@ -363,7 +360,14 @@ const RecordedPlayback = ({ route }) => {
Synchro audio / vidéo Synchro audio / vidéo
</Text> </Text>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 16 }}> <View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 16,
}}
>
<Pressable <Pressable
onPress={() => handleSyncOffsetChange(clampSyncOffsetMs(currentSyncOffsetMs - 100))} onPress={() => handleSyncOffsetChange(clampSyncOffsetMs(currentSyncOffsetMs - 100))}
disabled={!songUrl || currentSyncOffsetMs <= -2000} disabled={!songUrl || currentSyncOffsetMs <= -2000}
+39 -38
View File
@@ -78,6 +78,24 @@ const RecordedPlayback = ({ route }) => {
[audioPlayer, videoUri] [audioPlayer, videoUri]
) )
const syncVideoToAudio = useCallback(() => {
const video = videoElRef.current
if (!video || !videoUri) return
const rawAudioMs = getCurrentRawAudioPositionMs()
const timelineMs = getTimelinePositionMs(rawAudioMs, syncOffsetRef.current)
const expectedVideoMs = getVideoPositionMs(timelineMs, syncOffsetRef.current)
video.currentTime = expectedVideoMs / 1000
}, [getCurrentRawAudioPositionMs, videoUri])
const playSynchronized = useCallback(async () => {
if (audioPlayer && songUrl) await audioPlayer.play?.()
if (videoElRef.current && videoUri) {
videoElRef.current.muted = true
await videoElRef.current.play().catch(() => {})
}
syncVideoToAudio()
}, [audioPlayer, songUrl, syncVideoToAudio, videoUri])
const stopPlayback = useCallback(async () => { const stopPlayback = useCallback(async () => {
try { try {
if (audioPlayer) await audioPlayer.pause?.() if (audioPlayer) await audioPlayer.pause?.()
@@ -113,15 +131,7 @@ const RecordedPlayback = ({ route }) => {
const start = async () => { const start = async () => {
try { try {
await alignPlaybackToTimeline(0, initialSyncOffsetMs) await alignPlaybackToTimeline(0, initialSyncOffsetMs)
await playSynchronized()
if (audioPlayer && songUrl) {
await audioPlayer.play?.()
}
if (videoElRef.current && videoUri) {
videoElRef.current.muted = true
videoElRef.current.play().catch(() => {})
}
} catch {} } catch {}
} }
@@ -131,7 +141,7 @@ const RecordedPlayback = ({ route }) => {
playbackEndedRef.current = false playbackEndedRef.current = false
void stopPlayback() void stopPlayback()
} }
}, [alignPlaybackToTimeline, audioPlayer, initialSyncOffsetMs, songUrl, stopPlayback, videoUri]) }, [alignPlaybackToTimeline, initialSyncOffsetMs, playSynchronized, stopPlayback])
useEffect(() => { useEffect(() => {
const id = setInterval(() => { const id = setInterval(() => {
@@ -156,7 +166,7 @@ const RecordedPlayback = ({ route }) => {
const currentVideoMs = Number(videoElRef.current.currentTime || 0) * 1000 const currentVideoMs = Number(videoElRef.current.currentTime || 0) * 1000
const driftMs = Math.abs(currentVideoMs - expectedVideoMs) const driftMs = Math.abs(currentVideoMs - expectedVideoMs)
if (driftMs > 350) { if (driftMs > 120) {
videoElRef.current.currentTime = Math.max(0, expectedVideoMs / 1000) videoElRef.current.currentTime = Math.max(0, expectedVideoMs / 1000)
} }
} }
@@ -194,19 +204,9 @@ const RecordedPlayback = ({ route }) => {
const resumeAfterSeek = useCallback(async () => { const resumeAfterSeek = useCallback(async () => {
try { try {
if (audioPlayer?.resume) { await playSynchronized()
await audioPlayer.resume?.()
} else if (audioPlayer) {
await audioPlayer.play?.()
}
} catch {} } catch {}
try { }, [playSynchronized])
if (videoElRef.current && videoUri) {
videoElRef.current.muted = true
videoElRef.current.play().catch(() => {})
}
} catch {}
}, [audioPlayer, videoUri])
const handleSyncOffsetChange = useCallback( const handleSyncOffsetChange = useCallback(
(nextOffsetMs) => { (nextOffsetMs) => {
@@ -238,7 +238,12 @@ const RecordedPlayback = ({ route }) => {
void alignPlaybackToTimeline(nextTimelineMs, safeOffsetMs) void alignPlaybackToTimeline(nextTimelineMs, safeOffsetMs)
}, },
[alignPlaybackToTimeline, audioPlayer?.playing, getCurrentRawAudioDurationMs, getCurrentRawAudioPositionMs] [
alignPlaybackToTimeline,
audioPlayer?.playing,
getCurrentRawAudioDurationMs,
getCurrentRawAudioPositionMs,
]
) )
useEffect(() => { useEffect(() => {
@@ -278,20 +283,9 @@ const RecordedPlayback = ({ route }) => {
playbackEndedRef.current = false playbackEndedRef.current = false
if (songUrl && audioPlayer) { await playSynchronized()
if (audioPlayer?.resume) {
await audioPlayer.resume?.()
} else {
await audioPlayer.play?.()
}
}
if (videoElRef.current && videoUri) {
videoElRef.current.muted = true
videoElRef.current.play().catch(() => {})
}
} catch {} } catch {}
}, [alignPlaybackToTimeline, audioPlayer, progressInfo, songUrl, stopPlayback, videoUri]) }, [alignPlaybackToTimeline, audioPlayer, playSynchronized, progressInfo, stopPlayback])
return ( return (
<Page <Page
@@ -419,7 +413,14 @@ const RecordedPlayback = ({ route }) => {
Synchro audio / vidéo Synchro audio / vidéo
</Text> </Text>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 16 }}> <View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 16,
}}
>
<Pressable <Pressable
onPress={() => handleSyncOffsetChange(clampSyncOffsetMs(currentSyncOffsetMs - 100))} onPress={() => handleSyncOffsetChange(clampSyncOffsetMs(currentSyncOffsetMs - 100))}
disabled={!songUrl || currentSyncOffsetMs <= -2000} disabled={!songUrl || currentSyncOffsetMs <= -2000}
+1
View File
@@ -23,6 +23,7 @@ const Playbacks = () => {
const [viewMode, setViewMode] = useState(initialFocusProjectId ? 'feed' : 'charts') const [viewMode, setViewMode] = useState(initialFocusProjectId ? 'feed' : 'charts')
const { data: rawPlaybacks = [], loadMore } = useDataFromRef({ const { data: rawPlaybacks = [], loadMore } = useDataFromRef({
ref: projectsRef.where('playbackUrl', '!=', null), ref: projectsRef.where('playbackUrl', '!=', null),
format: (docs) => docs.filter((item) => item?.playbackPublishedOnMusicLand !== false),
simpleRef: false, simpleRef: false,
listener: false, listener: false,
usePagination: true, usePagination: true,
+1
View File
@@ -38,6 +38,7 @@ const Playbacks = () => {
loading, loading,
} = useDataFromRef({ } = useDataFromRef({
ref: projectsRef.where('playbackUrl', '!=', null), ref: projectsRef.where('playbackUrl', '!=', null),
format: (docs) => docs.filter((item) => item?.playbackPublishedOnMusicLand !== false),
simpleRef: false, simpleRef: false,
listener: false, listener: false,
usePagination: true, usePagination: true,
@@ -40,12 +40,10 @@ const chunkArray = (items = [], size = 10) => {
const PlaybackCharts = ({ onPlaybackPress }) => { const PlaybackCharts = ({ onPlaybackPress }) => {
const isWeb = Platform.OS === 'web' const isWeb = Platform.OS === 'web'
const [selectedCategory, setSelectedCategory] = useState('Playbacks') const [selectedCategory, setSelectedCategory] = useState('Playbacks')
const { const { data: allPlaybacks, loading: allPlaybacksLoading } = useDataFromRef({
data: allPlaybacks,
loading: allPlaybacksLoading,
} = useDataFromRef({
ref: projectsRef.where('playbackUrl', '!=', null), ref: projectsRef.where('playbackUrl', '!=', null),
format: (docs) => docs.filter((item) => !!item?.playbackUrl), format: (docs) =>
docs.filter((item) => !!item?.playbackUrl && item?.playbackPublishedOnMusicLand !== false),
simpleRef: false, simpleRef: false,
listener: true, listener: true,
condition: true, condition: true,
@@ -53,7 +51,8 @@ const PlaybackCharts = ({ onPlaybackPress }) => {
const { data: topMonthPlaybacks, loading: topMonthLoading } = useDataFromRef({ const { data: topMonthPlaybacks, loading: topMonthLoading } = useDataFromRef({
ref: projectsRef.where('monthViews', '>', 0).orderBy('monthViews', 'desc').limit(20), ref: projectsRef.where('monthViews', '>', 0).orderBy('monthViews', 'desc').limit(20),
format: (docs) => docs.filter((item) => !!item?.playbackUrl), format: (docs) =>
docs.filter((item) => !!item?.playbackUrl && item?.playbackPublishedOnMusicLand !== false),
simpleRef: false, simpleRef: false,
listener: false, listener: false,
condition: true, condition: true,
@@ -61,7 +60,8 @@ const PlaybackCharts = ({ onPlaybackPress }) => {
const { data: allTimePlaybacks, loading: allTimeLoading } = useDataFromRef({ const { data: allTimePlaybacks, loading: allTimeLoading } = useDataFromRef({
ref: projectsRef.where('views', '>', 0).orderBy('views', 'desc').limit(20), ref: projectsRef.where('views', '>', 0).orderBy('views', 'desc').limit(20),
format: (docs) => docs.filter((item) => !!item?.playbackUrl), format: (docs) =>
docs.filter((item) => !!item?.playbackUrl && item?.playbackPublishedOnMusicLand !== false),
simpleRef: false, simpleRef: false,
listener: false, listener: false,
condition: true, condition: true,
@@ -19,7 +19,6 @@ import { Feather } from '@expo/vector-icons'
// Debug logging toggle for web playback // Debug logging toggle for web playback
const DEBUG_PLAYBACK_WEB = true const DEBUG_PLAYBACK_WEB = true
const PLAYBACK_LYRICS_LEAD_S = 0.18
// NOTE: Web-only implementation that uses a native <video> element instead of expo-video // NOTE: Web-only implementation that uses a native <video> element instead of expo-video
// - No Platform checks // - No Platform checks
@@ -474,11 +473,7 @@ const PlaybackItem = ({ item, isActive, userCache, getUserByUid, onBackgroundSyn
<View style={styles.lyricsContainer}> <View style={styles.lyricsContainer}>
<BlurView tint="dark" intensity={20} style={styles.lyricsCard}> <BlurView tint="dark" intensity={20} style={styles.lyricsCard}>
{alignedWords?.length > 0 ? ( {alignedWords?.length > 0 ? (
<KaraokeLyrics <KaraokeLyrics alignedWords={alignedWords} currentTimeS={currentTimeS} />
alignedWords={alignedWords}
currentTimeS={currentTimeS}
timeOffsetS={PLAYBACK_LYRICS_LEAD_S}
/>
) : ( ) : (
<Text style={styles.lyricsText}>{descriptionText}</Text> <Text style={styles.lyricsText}>{descriptionText}</Text>
)} )}
@@ -5,18 +5,12 @@ import KaraokeLyrics from '../../../components/KaraokeLyrics'
import { Palette } from '../../../styles' import { Palette } from '../../../styles'
import { FONT_FAMILY } from '../../../styles/Fonts' import { FONT_FAMILY } from '../../../styles/Fonts'
const PLAYBACK_LYRICS_LEAD_S = 0.18
const PlaybackLyricsCard = ({ alignedWords, currentTimeS, fallbackTitle }) => { const PlaybackLyricsCard = ({ alignedWords, currentTimeS, fallbackTitle }) => {
return ( return (
<View style={styles.container}> <View style={styles.container}>
<BlurView tint="dark" intensity={Platform.OS !== 'ios' ? 10 : 20} style={styles.blur}> <BlurView tint="dark" intensity={Platform.OS !== 'ios' ? 10 : 20} style={styles.blur}>
{alignedWords?.length > 0 ? ( {alignedWords?.length > 0 ? (
<KaraokeLyrics <KaraokeLyrics alignedWords={alignedWords} currentTimeS={currentTimeS} />
alignedWords={alignedWords}
currentTimeS={currentTimeS}
timeOffsetS={PLAYBACK_LYRICS_LEAD_S}
/>
) : ( ) : (
<Text style={styles.title}>{fallbackTitle || 'Description chanson'}</Text> <Text style={styles.title}>{fallbackTitle || 'Description chanson'}</Text>
)} )}
+2 -2
View File
@@ -5,11 +5,11 @@ import Page from '../layouts/Page'
import { Fonts } from '../styles' import { Fonts } from '../styles'
const privacyPolicy = `Politique de Confidentialité de minuit.starter const privacyPolicy = `Politique de Confidentialité de MusicLand
1. Introduction 1. Introduction
La présente Politique de Confidentialité s'applique à l'application "minuit.starter" éditée par MINUIT.AGENCY. Elle a pour objet d'informer les utilisateurs de la manière dont leurs informations personnelles sont collectées, utilisées et protégées. La présente Politique de Confidentialité s'applique à l'application "MusicLand" éditée par MINUIT.AGENCY. Elle a pour objet d'informer les utilisateurs de la manière dont leurs informations personnelles sont collectées, utilisées et protégées.
2. Collecte des données 2. Collecte des données
+182 -158
View File
@@ -1,14 +1,13 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Alert, Platform, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native' import { Platform, ScrollView, StyleSheet, Text, View } from 'react-native'
import { MaterialCommunityIcons } from '@expo/vector-icons'
import useMinuit from 'react-native-minuit/src/hooks/useMinuit.js' import useMinuit from 'react-native-minuit/src/hooks/useMinuit.js'
import * as FileSystem from 'expo-file-system' import * as FileSystem from 'expo-file-system'
import { shareAsync } from 'expo-sharing' import { shareAsync } from 'expo-sharing'
import { background } from '../../assets' import { background } from '../../assets'
import FullscreenIntroVideo from '../../components/FullscreenIntroVideo' import FullscreenIntroVideo from '../../components/FullscreenIntroVideo'
import BorderGradientButton from '../../components/BorderGradientButton' import BorderGradientButton from '../../components/BorderGradientButton'
import GradientButton from '../../components/GradientButton'
import MusicLandHeader from '../../components/MusicLandHeader' import MusicLandHeader from '../../components/MusicLandHeader'
import AppCheckbox from '../../components/AppCheckbox'
import firebase, { getFunctionsClient, projectsRef, serverTimestamp } from '../../config/firebase' import firebase, { getFunctionsClient, projectsRef, serverTimestamp } from '../../config/firebase'
import { uploadFileToFirebase } from '../../helpers/uploadToFirebase' import { uploadFileToFirebase } from '../../helpers/uploadToFirebase'
import Page from '../../layouts/Page' import Page from '../../layouts/Page'
@@ -20,15 +19,12 @@ import { Palette } from '../../styles'
import { FONT_FAMILY } from '../../styles/Fonts' import { FONT_FAMILY } from '../../styles/Fonts'
import { size } from '../../styles/Style' import { size } from '../../styles/Style'
import { getBlobForUrl, releaseBlobUrl } from '../../utils/blobUrlCache' import { getBlobForUrl, releaseBlobUrl } from '../../utils/blobUrlCache'
import ClubAdvantagesCard from '../Profile/components/ClubAdvantagesCard'
import CreateLyricsHeader from '../Writing/components/CreateLyricsHeader' import CreateLyricsHeader from '../Writing/components/CreateLyricsHeader'
import { Image as ExpoImage } from 'expo-image' import { Image as ExpoImage } from 'expo-image'
import SubscriptionConfirmModal from '../../components/SubscriptionConfirmModal'
import { isWeb } from '../../hooks/useLayoutType'
import { clampSyncOffsetMs, toSyncOffsetSeconds } from '../../utils/playbackSync' import { clampSyncOffsetMs, toSyncOffsetSeconds } from '../../utils/playbackSync'
const triggerDownload = async (url, title) => { const triggerDownload = async (url, title) => {
if (!url) return if (!url) return false
const baseName = (title || 'Playback').toString().trim() || 'Playback' const baseName = (title || 'Playback').toString().trim() || 'Playback'
const sanitized = baseName.replace(/[\\/:*?"<>|]/g, '-') const sanitized = baseName.replace(/[\\/:*?"<>|]/g, '-')
const extension = guessExtension(url) || 'mp4' const extension = guessExtension(url) || 'mp4'
@@ -53,6 +49,7 @@ const triggerDownload = async (url, title) => {
document.body.removeChild(anchor) document.body.removeChild(anchor)
// Revoke with delay to ensure browser captures it // Revoke with delay to ensure browser captures it
setTimeout(() => URL.revokeObjectURL(blobUrl), 150) setTimeout(() => URL.revokeObjectURL(blobUrl), 150)
return true
} catch (error) { } catch (error) {
console.log('[PlaybackDownload] web download fallback', { console.log('[PlaybackDownload] web download fallback', {
message: error?.message, message: error?.message,
@@ -67,6 +64,7 @@ const triggerDownload = async (url, title) => {
document.body.appendChild(anchor) document.body.appendChild(anchor)
anchor.click() anchor.click()
document.body.removeChild(anchor) document.body.removeChild(anchor)
return true
} catch (fallbackError) { } catch (fallbackError) {
console.log('[PlaybackDownload] anchor fallback failed', { console.log('[PlaybackDownload] anchor fallback failed', {
message: fallbackError?.message, message: fallbackError?.message,
@@ -74,7 +72,8 @@ const triggerDownload = async (url, title) => {
// Ultimate fallback: direct window open // Ultimate fallback: direct window open
try { try {
window.open(url, '_blank', 'noopener,noreferrer') window.open(url, '_blank', 'noopener,noreferrer')
} catch { } return true
} catch {}
} }
} }
} else { } else {
@@ -87,11 +86,13 @@ const triggerDownload = async (url, title) => {
if (shareAsync) { if (shareAsync) {
await shareAsync(localUri) await shareAsync(localUri)
} }
return true
} }
} catch (e) { } catch (e) {
console.error(e) console.error(e)
} }
} }
return false
} }
const guessExtension = (inputUri = '') => { const guessExtension = (inputUri = '') => {
@@ -146,7 +147,12 @@ const PlaybackDownload = ({ route }) => {
const { currentUID, selectedProject } = useUserData() const { currentUID, selectedProject } = useUserData()
const { hasActiveSubscription, hasPurchased, videos } = useUser() || {} const { hasActiveSubscription, hasPurchased, videos } = useUser() || {}
const { createPlaybackDownloadCheckout } = useStripe() const { createPlaybackDownloadCheckout } = useStripe()
const { action: routeAction, uri, project: routeProject, syncOffsetMs: routeSyncOffsetMs = 0 } = route.params || {} const {
action: routeAction,
uri,
project: routeProject,
syncOffsetMs: routeSyncOffsetMs = 0,
} = route.params || {}
const action = routeAction || 'playback' const action = routeAction || 'playback'
const syncOffsetMs = clampSyncOffsetMs(routeSyncOffsetMs) const syncOffsetMs = clampSyncOffsetMs(routeSyncOffsetMs)
console.log('[PlaybackDownload] route params', { console.log('[PlaybackDownload] route params', {
@@ -158,29 +164,30 @@ const PlaybackDownload = ({ route }) => {
}) })
const { setIsLoading, setTooltip } = useMinuit() const { setIsLoading, setTooltip } = useMinuit()
const [isAfterPlaybackVideoVisible, setIsAfterPlaybackVideoVisible] = useState(false) const [isAfterPlaybackVideoVisible, setIsAfterPlaybackVideoVisible] = useState(false)
const [showConfirmModal, setShowConfirmModal] = useState(false)
const [pendingPlaybackUrl, setPendingPlaybackUrl] = useState(routeProject?.playbackUrl || null) const [pendingPlaybackUrl, setPendingPlaybackUrl] = useState(routeProject?.playbackUrl || null)
const [isPublishing, setIsPublishing] = useState(false) const [isPublishing, setIsPublishing] = useState(false)
const [isDownloading, setIsDownloading] = useState(false) const [isDownloading, setIsDownloading] = useState(false)
const [isCheckoutLaunching, setIsCheckoutLaunching] = useState(false) const [isCheckoutLaunching, setIsCheckoutLaunching] = useState(false)
const [downloadPaymentPending, setDownloadPaymentPending] = useState(false) const [downloadPaymentPending, setDownloadPaymentPending] = useState(false)
const [hasAcceptedPublication, setHasAcceptedPublication] = useState(true) const [pendingDownloadIntent, setPendingDownloadIntent] = useState(null)
const hasShownAfterPlaybackRef = useRef(false) const hasShownAfterPlaybackRef = useRef(false)
const publishSuccessMessage = action === 'playback' ? 'Playback publié !' : 'Chanson publiée !'
const projectForDownload = useMemo(() => { const projectForDownload = useMemo(() => {
if (routeProject?.id && selectedProject?.id && routeProject.id === selectedProject.id) { if (routeProject && typeof routeProject === 'object' && routeProject.id) {
if (selectedProject?.id === routeProject.id) {
return selectedProject return selectedProject
} }
return routeProject || selectedProject || null return routeProject
}
return selectedProject?.id ? selectedProject : null
}, [routeProject, selectedProject]) }, [routeProject, selectedProject])
const playbackDownloadStatus = projectForDownload?.playbackDownloadPurchase?.status || null const playbackDownloadStatus = projectForDownload?.playbackDownloadPurchase?.status || null
const hasPaidPlaybackDownload = playbackDownloadStatus === 'paid' const hasPaidPlaybackDownload = playbackDownloadStatus === 'paid'
const canDownloadPlayback = hasActiveSubscription || hasPaidPlaybackDownload || hasPurchased const canDownloadPlayback = hasActiveSubscription || hasPaidPlaybackDownload || hasPurchased
const downloadLabel = canDownloadPlayback const downloadAccessLabel = canDownloadPlayback
? 'Télécharger le playback' ? 'Téléchargement inclus avec ton accès actuel.'
: 'Acheter le playback pour 1,99' : 'Le téléchargement de ce playback coûte 1,99 €.'
const afterPlaybackUrl = useMemo(() => { const afterPlaybackUrl = useMemo(() => {
if (!videos) return null if (!videos) return null
@@ -302,36 +309,66 @@ const PlaybackDownload = ({ route }) => {
releaseBlobUrl(uri || null) releaseBlobUrl(uri || null)
setIsLoading(false) setIsLoading(false)
} }
}, [action, currentUID, pendingPlaybackUrl, projectForDownload, resolveAudioUrl, setIsLoading, syncOffsetMs, uri]) }, [
action,
currentUID,
pendingPlaybackUrl,
projectForDownload,
resolveAudioUrl,
setIsLoading,
syncOffsetMs,
uri,
])
const executePlaybackChoice = useCallback(
async (intent) => {
if (intent !== 'personal' && intent !== 'publish') return
if (isPublishing || isDownloading || !projectForDownload?.id) return
const handleDownloadUri = useCallback(async () => {
if (isPublishing || isDownloading) return
try { try {
setIsDownloading(true) setIsDownloading(true)
const resultURI = await processMerge() setIsPublishing(intent === 'publish')
const playbackUrl = await processMerge()
const didDownload = await triggerDownload(playbackUrl, projectForDownload.title)
if (!didDownload) {
throw new Error("Le téléchargement du playback n'a pas abouti.")
}
await projectsRef.doc(projectForDownload.id).set(
{
playbackUrl,
playbackPublishedOnMusicLand: intent === 'publish',
playbackPublishedOnMusicLandAt: intent === 'publish' ? serverTimestamp() : null,
updatedAt: serverTimestamp(),
},
{ merge: true }
)
setTooltip({ setTooltip({
type: 'success', type: 'success',
text: 'Playback prêt à télécharger', text:
intent === 'publish'
? 'Playback téléchargé et publié sur MusicLand !'
: 'Playback téléchargé à des fins personnelles.',
}) })
await triggerDownload(resultURI, projectForDownload?.title) if (intent === 'publish') {
navigate(Routes.Home)
}
} catch (error) { } catch (error) {
setTooltip({ setTooltip({
type: 'error', type: 'error',
text: String(error?.message || 'Erreur lors de la publication du playback'), text: String(error?.message || "Impossible de terminer l'opération."),
}) })
} finally { } finally {
setIsDownloading(false) setIsDownloading(false)
setIsPublishing(false)
} }
}, [ },
isDownloading, [isDownloading, isPublishing, processMerge, projectForDownload, setTooltip]
isPublishing, )
processMerge,
projectForDownload,
setTooltip,
])
const startPlaybackDownloadCheckout = useCallback( const startPlaybackDownloadCheckout = useCallback(
async ({ force = false } = {}) => { async (intent) => {
if (!projectForDownload?.id) { if (!projectForDownload?.id) {
setTooltip({ setTooltip({
type: 'error', type: 'error',
@@ -340,19 +377,21 @@ const PlaybackDownload = ({ route }) => {
return return
} }
if (!force && (isCheckoutLaunching || downloadPaymentPending)) { if (isCheckoutLaunching) {
return return
} }
setPendingDownloadIntent(intent)
setIsCheckoutLaunching(true) setIsCheckoutLaunching(true)
setDownloadPaymentPending(true) setDownloadPaymentPending(true)
try { try {
await createPlaybackDownloadCheckout(projectForDownload.id) await createPlaybackDownloadCheckout(projectForDownload.id)
} catch (error) { } catch (error) {
setDownloadPaymentPending(false) setDownloadPaymentPending(false)
setPendingDownloadIntent(null)
setTooltip({ setTooltip({
type: 'error', type: 'error',
text: error?.message || "Une erreur est survenue lors du paiement.", text: error?.message || 'Une erreur est survenue lors du paiement.',
}) })
} finally { } finally {
setIsCheckoutLaunching(false) setIsCheckoutLaunching(false)
@@ -360,63 +399,51 @@ const PlaybackDownload = ({ route }) => {
}, },
[ [
createPlaybackDownloadCheckout, createPlaybackDownloadCheckout,
downloadPaymentPending,
isCheckoutLaunching, isCheckoutLaunching,
projectForDownload, projectForDownload,
setTooltip, setTooltip,
] ]
) )
const handleDownloadPress = async () => { const handlePlaybackChoice = useCallback(
if (!canDownloadPlayback) return startPlaybackDownloadCheckout() (intent) => {
if (isDownloading || isPublishing || isCheckoutLaunching) return
if (isDownloading || isPublishing) return if (canDownloadPlayback) {
executePlaybackChoice(intent)
try {
setIsDownloading(true)
const url = await processMerge()
await triggerDownload(url, projectForDownload?.title)
} catch (err) {
setTooltip({ type: 'error', text: err.message })
} finally {
setIsDownloading(false)
}
}
useEffect(() => {
if (!downloadPaymentPending || !hasPaidPlaybackDownload || isDownloading) {
return return
} }
setDownloadPaymentPending(false) startPlaybackDownloadCheckout(intent)
handleDownloadUri()
}, [downloadPaymentPending, handleDownloadUri, hasPaidPlaybackDownload, isDownloading])
const handlePublish = async () => {
if (!hasAcceptedPublication) return
if (isPublishing) return
try {
setIsPublishing(true)
const url = await processMerge()
await projectsRef.doc(projectForDownload.id).set(
{
playbackUrl: url,
updatedAt: serverTimestamp(),
}, },
{ merge: true } [
canDownloadPlayback,
executePlaybackChoice,
isCheckoutLaunching,
isDownloading,
isPublishing,
startPlaybackDownloadCheckout,
]
) )
navigate(Routes.PublishYoutube, { projectId: projectForDownload.id }) useEffect(() => {
} catch (err) { if (
setTooltip({ !downloadPaymentPending ||
type: 'error', !hasPaidPlaybackDownload ||
text: String(err?.message || 'Erreur de publication'), !pendingDownloadIntent ||
}) isDownloading
} finally { ) {
setIsPublishing(false) return
}
} }
const intent = pendingDownloadIntent
setDownloadPaymentPending(false)
setPendingDownloadIntent(null)
executePlaybackChoice(intent)
}, [
downloadPaymentPending,
executePlaybackChoice,
hasPaidPlaybackDownload,
isDownloading,
pendingDownloadIntent,
])
return ( return (
<> <>
<FullscreenIntroVideo <FullscreenIntroVideo
@@ -425,23 +452,21 @@ const PlaybackDownload = ({ route }) => {
onClose={() => setIsAfterPlaybackVideoVisible(false)} onClose={() => setIsAfterPlaybackVideoVisible(false)}
/> />
<Page <Page
backgroundImg={ backgroundImg={action === 'playback' ? background.playbackBG2 : background.productionBG2}
action === 'playback' ? background.playbackBG2 : background.productionBG2
}
backgroundColor="#07111B" backgroundColor="#07111B"
headerType="NONE" headerType="NONE"
> >
<MusicLandHeader onPressBack={goBack} progress={50} /> <MusicLandHeader onPressBack={goBack} progress={50} />
<ScrollView contentContainerStyle={styles.container} showsVerticalScrollIndicator={false}> <ScrollView contentContainerStyle={styles.container} showsVerticalScrollIndicator={false}>
<CreateLyricsHeader title={'Publier ton playback'} /> <CreateLyricsHeader
title="Ton playback est prêt"
subTitle="Choisis l'utilisation de ta vidéo."
/>
<ClubAdvantagesCard <View style={styles.summaryCard}>
style={styles.clubCardSpacing}
topContent={
<View style={styles.coverRow}>
{projectForDownload?.coverUrl ? ( {projectForDownload?.coverUrl ? (
<ExpoImage <ExpoImage
source={{ uri: projectForDownload?.coverUrl }} source={{ uri: projectForDownload.coverUrl }}
style={styles.coverImage} style={styles.coverImage}
contentFit="cover" contentFit="cover"
/> />
@@ -450,50 +475,36 @@ const PlaybackDownload = ({ route }) => {
<Text style={styles.placeholderText}>Aucune pochette</Text> <Text style={styles.placeholderText}>Aucune pochette</Text>
</View> </View>
)} )}
<Text style={styles.playbackTitle}>{projectForDownload?.title || 'Mon playback'}</Text>
</View>
<View style={styles.downloadColumn}> <View style={styles.choiceSection}>
<Pressable <Text style={styles.sectionTitle}>Téléchargement</Text>
style={[ <Text style={styles.sectionDescription}>{downloadAccessLabel}</Text>
styles.downloadTile, <BorderGradientButton
(isPublishing || isDownloading || isCheckoutLaunching) && { opacity: 0.6 }, title="Télécharger à des fins personnelles"
]} onPress={() => handlePlaybackChoice('personal')}
onPress={handleDownloadPress}
disabled={isPublishing || isDownloading || isCheckoutLaunching} disabled={isPublishing || isDownloading || isCheckoutLaunching}
> loading={isDownloading && !isPublishing}
<MaterialCommunityIcons name="download" size={22} color={Palette.white} /> loadingText="Téléchargement en cours..."
<Text style={styles.downloadText}>{downloadLabel}</Text> height={58}
</Pressable> titleStyle={styles.choiceButtonText}
/>
<GradientButton
title={
isPublishing
? 'Téléchargement et publication en cours...'
: 'Télécharger et publier sur MusicLand'
}
onPress={() => handlePlaybackChoice('publish')}
disabled={isPublishing || isDownloading || isCheckoutLaunching}
height={58}
textStyle={styles.choiceButtonText}
/>
{!canDownloadPlayback && downloadPaymentPending ? ( {!canDownloadPlayback && downloadPaymentPending ? (
<Text style={styles.downloadPendingText}> <Text style={styles.downloadPendingText}>Paiement en attente de confirmation...</Text>
Paiement en attente de confirmation...
</Text>
) : null} ) : null}
</View> </View>
</View>
}
/>
<View style={styles.publishConsentContainer}>
<AppCheckbox
selected={hasAcceptedPublication}
onPress={() => setHasAcceptedPublication((prevState) => !prevState)}
label="J'accepte la diffusion de mon playback sur Musicland."
/>
<Text style={styles.publishConsentDescription}>
Cette confirmation est requise avant de lancer la publication.
</Text>
</View>
<BorderGradientButton
title={'Publier'}
onPress={() => {
handlePublish()
}}
disabled={isPublishing || !hasAcceptedPublication}
loading={isPublishing}
loadingText="Publication en cours..."
containerStyle={styles.continueButton}
/>
</ScrollView> </ScrollView>
</Page> </Page>
</> </>
@@ -505,15 +516,22 @@ export default PlaybackDownload
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flexGrow: 1, flexGrow: 1,
width: '100%',
maxWidth: 620,
alignSelf: 'center',
paddingHorizontal: 16, paddingHorizontal: 16,
paddingVertical: 12, paddingVertical: 12,
gap: 14, gap: 14,
}, },
coverRow: { summaryCard: {
flexDirection: isWeb ? 'row' : 'column',
alignItems: 'center', alignItems: 'center',
justifyContent: isWeb ? 'center' : 'center', justifyContent: 'center',
gap: 12, gap: 12,
padding: 16,
borderRadius: 20,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.14)',
backgroundColor: 'rgba(5, 12, 24, 0.76)',
}, },
coverImage: { coverImage: {
...size({ size: 140 }), ...size({ size: 140 }),
@@ -521,23 +539,45 @@ const styles = StyleSheet.create({
borderWidth: 1, borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.18)', borderColor: 'rgba(255, 255, 255, 0.18)',
}, },
downloadTile: { coverPlaceholder: {
flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
gap: 10, justifyContent: 'center',
paddingVertical: 10, backgroundColor: 'rgba(255, 255, 255, 0.06)',
paddingHorizontal: 14,
borderRadius: 14,
backgroundColor: '#8C4BFF',
}, },
downloadText: { placeholderText: {
fontFamily: FONT_FAMILY.InterMedium,
color: Palette.grayMid,
},
playbackTitle: {
fontFamily: FONT_FAMILY.InterSemiBold, fontFamily: FONT_FAMILY.InterSemiBold,
fontSize: 15, fontSize: 18,
color: Palette.white,
textAlign: 'center',
},
choiceSection: {
gap: 10,
padding: 16,
borderRadius: 20,
backgroundColor: 'rgba(37, 36, 56, 0.94)',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.12)',
},
sectionTitle: {
fontFamily: FONT_FAMILY.InterSemiBold,
fontSize: 18,
color: Palette.white, color: Palette.white,
}, },
downloadColumn: { sectionDescription: {
alignItems: 'center', fontFamily: FONT_FAMILY.InterRegular,
gap: 4, fontSize: 14,
lineHeight: 20,
color: Palette.white,
opacity: 0.8,
marginBottom: 4,
},
choiceButtonText: {
textAlign: 'center',
fontSize: 14,
}, },
downloadPendingText: { downloadPendingText: {
marginTop: 6, marginTop: 6,
@@ -546,20 +586,4 @@ const styles = StyleSheet.create({
color: Palette.white, color: Palette.white,
opacity: 0.8, opacity: 0.8,
}, },
clubCardSpacing: {
marginTop: 10,
},
publishConsentContainer: {
gap: 6,
marginTop: 2,
},
publishConsentDescription: {
fontFamily: FONT_FAMILY.InterRegular,
fontSize: 13,
color: Palette.white,
opacity: 0.8,
},
continueButton: {
marginTop: 10,
},
}) })
+2 -2
View File
@@ -42,7 +42,7 @@ const StreamSong = () => {
if (!hasAcceptedPublication) { if (!hasAcceptedPublication) {
setTooltip({ setTooltip({
type: 'error', type: 'error',
text: 'Confirme la diffusion sur Musicland et YouTube avant de publier', text: 'Confirme la diffusion sur MusicLand avant de publier',
}) })
return return
} }
@@ -112,7 +112,7 @@ const StreamSong = () => {
<AppCheckbox <AppCheckbox
selected={hasAcceptedPublication} selected={hasAcceptedPublication}
onPress={() => setHasAcceptedPublication((prevState) => !prevState)} onPress={() => setHasAcceptedPublication((prevState) => !prevState)}
label="J'accepte la diffusion de mon contenu sur Musicland et YouTube." label="J'accepte la diffusion de mon contenu sur MusicLand."
/> />
<Text <Text
style={{ style={{
+7 -2
View File
@@ -304,10 +304,15 @@ const Profile = () => {
refreshArray: [targetUserId], refreshArray: [targetUserId],
}) })
const displayedProjects = isSelf ? selfProjects : otherUserProjects const displayedProjects = isSelf
? selfProjects
: otherUserProjects.filter((project) => project?.songPublishedOnMusicLand !== false)
const displayedPlaybacksSource = isSelf ? selfPlaybacks : otherUserProjects const displayedPlaybacksSource = isSelf ? selfPlaybacks : otherUserProjects
const displayedPlaybacks = Array.isArray(displayedPlaybacksSource) const displayedPlaybacks = Array.isArray(displayedPlaybacksSource)
? displayedPlaybacksSource.filter((project) => project?.playbackUrl) ? displayedPlaybacksSource.filter(
(project) =>
project?.playbackUrl && (isSelf || project?.playbackPublishedOnMusicLand !== false)
)
: [] : []
const showHeader = isWeb const showHeader = isWeb
const showBackButton = !params?.noBack && !isWeb const showBackButton = !params?.noBack && !isWeb
-327
View File
@@ -1,327 +0,0 @@
import { useRoute } from '@react-navigation/native'
import React, { useCallback, useMemo, useState } from 'react'
import { Linking, StyleSheet, Text, View } from 'react-native'
import useMinuit from 'react-native-minuit/src/hooks/useMinuit.js'
import { background } from '../../assets'
import AppCheckbox from '../../components/AppCheckbox'
import BackgroundVideo from '../../components/BackgroundVideo'
import BorderGradientButton from '../../components/BorderGradientButton'
import GradientButton from '../../components/GradientButton'
import MusicLandHeader from '../../components/MusicLandHeader'
import alert from '../../components/Alert'
import firebase, { projectsRef, serverTimestamp } from '../../config/firebase'
import Page from '../../layouts/Page'
import { goBack, navigate } from '../../navigation/NavigationService'
import { Routes } from '../../navigation'
import { useUser } from '../../providers/UserDataProvider'
import { Palette, gutters } from '../../styles'
import { FONT_FAMILY } from '../../styles/Fonts'
import { isWeb } from '../../hooks/useLayoutType'
const PublishYoutube = () => {
const route = useRoute()
const routeProjectId = route?.params?.projectId ?? null
const { userProjects = [], selectedProject, videos } = useUser()
const { setTooltip } = useMinuit()
const project = useMemo(() => {
if (routeProjectId) {
return userProjects.find((item) => item?.id === routeProjectId) || selectedProject || null
}
if (selectedProject?.id) {
return selectedProject
}
return userProjects.length ? userProjects[0] : null
}, [routeProjectId, selectedProject, userProjects])
const projectId = project?.id || routeProjectId || null
const introVideo = isWeb ? videos?.benhaiWeb : videos?.benhai
const [saving, setSaving] = useState(false)
const [hasAcceptedPublication, setHasAcceptedPublication] = useState(true)
const youtubeUrl = project?.youtubeUrl ?? null
const youtubeStatus = project?.youtubeStatus ?? null
const youtubeError = project?.youtubeError ?? null
const playbackReady = !!project?.playbackUrl
const hasYoutubePublication = !!youtubeUrl
const isPublishing = useMemo(
() => ['PUBLISHING', 'UPLOADING', 'PROCESSING', 'QUEUED'].includes(youtubeStatus),
[youtubeStatus]
)
const publishDisabled =
saving || isPublishing || !playbackReady || !projectId || !hasAcceptedPublication
const publishButtonLabel = isPublishing
? 'Publication en cours...'
: hasYoutubePublication
? 'Mettre à jour sur YouTube'
: 'Publier sur YouTube'
const statusMessage = useMemo(() => {
if (!playbackReady) {
return 'Termine la création de ton playback avec Theo pour débloquer la publication YouTube.'
}
if (isPublishing) {
return 'La publication de ta vidéo est en cours... Patiente un instant.'
}
if (youtubeError || youtubeStatus === 'FAILED') {
return 'La dernière tentative de publication a échoué. Tu peux réessayer ci-dessous.'
}
if (hasYoutubePublication) {
return 'Ta vidéo est publiée sur la chaîne YouTube MusicLand. Tu peux la partager dès maintenant.'
}
return 'Partage ton playback sur la chaîne YouTube MusicLand.'
}, [hasYoutubePublication, isPublishing, playbackReady, youtubeError, youtubeStatus])
const handleOpenYoutube = useCallback(async () => {
if (!youtubeUrl) {
return
}
try {
await Linking.openURL(youtubeUrl)
} catch (_error) {
setTooltip?.({
type: 'error',
text: "Impossible d'ouvrir le lien YouTube",
})
}
}, [setTooltip, youtubeUrl])
const handleMarkPublished = useCallback(async () => {
if (!projectId) {
setTooltip?.({
type: 'error',
text: 'Sélectionnez un projet avant de publier',
})
return
}
if (!playbackReady) {
setTooltip?.({
type: 'error',
text: "Aucun playback n'est disponible pour la publication",
})
return
}
setSaving(true)
try {
// publication sur youtube en standby
// const publishCallable = firebase.functions().httpsCallable('youtube-publishPlaybackToYoutube')
// await publishCallable({ projectId })
setTooltip?.({
type: 'success',
text: 'Publication lancée sur YouTube',
})
} catch (error) {
setTooltip?.({
type: 'error',
text: error?.message || 'Impossible de lancer la publication',
})
} finally {
setSaving(false)
}
}, [playbackReady, projectId, setTooltip])
const handleConfirmPublish = useCallback(() => {
if (!hasAcceptedPublication) {
setTooltip?.({
type: 'error',
text: 'Confirme la diffusion sur YouTube avant de publier',
})
return
}
if (publishDisabled) {
return
}
const confirmTitle = hasYoutubePublication
? 'Confirmer la mise à jour'
: 'Confirmer la publication'
const confirmDescription = hasYoutubePublication
? 'La vidéo existe déjà sur YouTube. Confirme que tu souhaites remplacer la version publiée.'
: 'Confirme que tu souhaites publier ce playback sur YouTube. Il sera visible publiquement.'
alert(confirmTitle, confirmDescription, [
{ text: 'Annuler', style: 'cancel' },
{
text: hasYoutubePublication ? 'Mettre à jour' : 'Je confirme',
onPress: handleMarkPublished,
},
])
}, [
handleMarkPublished,
hasAcceptedPublication,
hasYoutubePublication,
publishDisabled,
setTooltip,
])
const handleResetPublication = useCallback(async () => {
if (!projectId) {
setTooltip?.({
type: 'error',
text: 'Sélectionnez un projet avant de réinitialiser',
})
return
}
setSaving(true)
try {
await projectsRef.doc(projectId).set(
{
youtubePublished: false,
youtubeUrl: null,
youtubeVideoId: null,
youtubePublishedAt: null,
youtubeStatus: 'IDLE',
youtubeError: null,
updatedAt: serverTimestamp(),
},
{ merge: true }
)
setTooltip?.({
type: 'success',
text: 'Publication YouTube réinitialisée',
})
} catch (error) {
setTooltip?.({
type: 'error',
text: error?.message || 'Réinitialisation impossible',
})
} finally {
setSaving(false)
}
}, [projectId, setTooltip])
const handleGoHome = useCallback(() => {
navigate(Routes.BottomTab, {
screen: Routes.HomeStack,
params: {
screen: Routes.Home,
},
})
}, [])
return (
<Page
backgroundImg={background.playbackBG2}
backgroundContent={
<BackgroundVideo
source={introVideo}
soundButtonStyle={isWeb ? styles.rightSideControl : undefined}
/>
}
containerStyle={isWeb ? styles.videoPage : undefined}
headerType="NONE"
>
<MusicLandHeader progress={100} onPressBack={goBack} />
<View
style={{
flex: 1,
paddingHorizontal: gutters,
paddingBottom: gutters * 2,
justifyContent: 'space-between',
}}
>
<View style={{ alignItems: 'center', gap: 18 }}>
<Text
style={{
fontSize: 22,
fontFamily: FONT_FAMILY.InterSemiBold,
color: Palette.white,
textAlign: 'center',
}}
>
{hasYoutubePublication ? 'Ta vidéo est déjà sur YouTube' : 'Publier sur YouTube'}
</Text>
<Text
style={{
fontSize: 14,
fontFamily: FONT_FAMILY.InterRegular,
color: Palette.white,
opacity: 0.9,
textAlign: 'center',
lineHeight: 20,
}}
>
{statusMessage}
</Text>
{youtubeError && (
<Text
style={{
fontSize: 13,
fontFamily: FONT_FAMILY.InterRegular,
color: Palette.red,
textAlign: 'center',
}}
>
{youtubeError}
</Text>
)}
{hasYoutubePublication && youtubeUrl && (
<Text
style={{
fontSize: 13,
fontFamily: FONT_FAMILY.InterMedium,
color: Palette.white,
textAlign: 'center',
opacity: 0.8,
}}
numberOfLines={2}
>
{youtubeUrl}
</Text>
)}
</View>
<View style={{ gap: 14 }}>
<View style={styles.consentContainer}>
<AppCheckbox
selected={hasAcceptedPublication}
onPress={() => setHasAcceptedPublication((prevState) => !prevState)}
label="J'accepte la diffusion de mon contenu sur YouTube."
/>
<Text style={styles.consentDescription}>
Cette confirmation est requise avant toute publication sur YouTube.
</Text>
</View>
<GradientButton
title={publishButtonLabel}
onPress={handleConfirmPublish}
disabled={publishDisabled}
/>
{hasYoutubePublication && youtubeUrl && (
<BorderGradientButton
title="Voir la vidéo sur YouTube"
onPress={handleOpenYoutube}
disabled={saving || isPublishing}
/>
)}
{hasYoutubePublication && (
<BorderGradientButton
title="Réinitialiser la publication"
onPress={handleResetPublication}
disabled={saving || isPublishing}
/>
)}
<BorderGradientButton title="Retour à l'accueil" onPress={handleGoHome} />
</View>
</View>
</Page>
)
}
const styles = StyleSheet.create({
videoPage: {
alignSelf: 'center',
},
rightSideControl: {
right: 24,
left: 'auto',
},
consentContainer: {
gap: 6,
},
consentDescription: {
fontSize: 13,
fontFamily: FONT_FAMILY.InterRegular,
color: Palette.white,
opacity: 0.8,
},
})
export default PublishYoutube
+141 -13
View File
@@ -10,6 +10,7 @@ import {
Pressable, Pressable,
StyleSheet, StyleSheet,
Text, Text,
useWindowDimensions,
View, View,
} from 'react-native' } from 'react-native'
import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view' import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view'
@@ -31,7 +32,8 @@ import { getRouteAfterAuthentication } from '../utils/registrationFlow'
const LANGUAGE_STORAGE_KEY = 'preferredLanguage' const LANGUAGE_STORAGE_KEY = 'preferredLanguage'
const Register = () => { const Register = ({ navigation, route }) => {
const { width: viewportWidth, height: viewportHeight } = useWindowDimensions()
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [firstName, setFirstName] = useState('') const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('') const [lastName, setLastName] = useState('')
@@ -39,6 +41,27 @@ const Register = () => {
const [birthDate, setBirthDate] = useState(null) const [birthDate, setBirthDate] = useState(null)
const [showDatePicker, setShowDatePicker] = useState(false) const [showDatePicker, setShowDatePicker] = useState(false)
const [, setTooltip] = useGlobal('_tooltip') const [, setTooltip] = useGlobal('_tooltip')
const isCompactWeb = isWeb && viewportWidth < 700
const webFormHeight = isWeb
? Math.max(360, Math.min(720, viewportHeight - (isCompactWeb ? 72 : 96)))
: undefined
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 afterSocialAuth = useCallback(async (authResult) => {
const uid = firebase.auth().currentUser?.uid const uid = firebase.auth().currentUser?.uid
@@ -57,9 +80,26 @@ const Register = () => {
} = useSocialAuth({ } = useSocialAuth({
onSuccess: afterSocialAuth, onSuccess: afterSocialAuth,
preferredLanguage, preferredLanguage,
termsAccepted: route.params?.termsAccepted === true,
}) })
const isBusy = socialLoading const isBusy = socialLoading
const termsAccepted = route.params?.termsAccepted === true
const openRegistrationTerms = useCallback(() => {
navigation.navigate(Routes.RegistrationTerms, { sourceRouteKey: route.key })
}, [navigation, route.key])
const requireTermsAcceptance = useCallback(
(action) => {
if (!termsAccepted) {
openRegistrationTerms()
return
}
action()
},
[openRegistrationTerms, termsAccepted]
)
useEffect(() => { useEffect(() => {
AsyncStorage.getItem(LANGUAGE_STORAGE_KEY) AsyncStorage.getItem(LANGUAGE_STORAGE_KEY)
@@ -98,12 +138,17 @@ const Register = () => {
}) })
return return
} }
if (!termsAccepted) {
openRegistrationTerms()
return
}
navigate(Routes.CreatePassword, { navigate(Routes.CreatePassword, {
email: email.trim(), email: email.trim(),
firstName: firstName.trim(), firstName: firstName.trim(),
lastName: lastName.trim(), lastName: lastName.trim(),
birthDate: birthDate ? birthDate.toISOString() : null, birthDate: birthDate ? birthDate.toISOString() : null,
preferredLanguage, preferredLanguage,
termsAccepted: true,
}) })
} }
@@ -115,8 +160,10 @@ const Register = () => {
} }
const renderFormContent = () => ( const renderFormContent = () => (
<View style={styles.formContent}> <View style={[styles.formContent, isCompactWeb ? styles.compactFormContent : null]}>
<Text style={styles.title}>Renseigne tes informations</Text> <Text style={[styles.title, isCompactWeb ? styles.compactTitle : null]}>
Renseigne tes informations
</Text>
{preferredLanguage && !isWeb ? ( {preferredLanguage && !isWeb ? (
<Text style={styles.languageInfo}> <Text style={styles.languageInfo}>
Langue choisie :{' '} Langue choisie :{' '}
@@ -200,14 +247,35 @@ const Register = () => {
/> />
<View style={styles.socialWrapper}> <View style={styles.socialWrapper}>
{isAppleSignInAvailable ? ( {isAppleSignInAvailable ? (
<AppleAuthButton onPress={signInWithApple} disabled={isBusy} loading={socialLoading} /> <AppleAuthButton
onPress={() => requireTermsAcceptance(signInWithApple)}
disabled={isBusy}
loading={socialLoading}
/>
) : null} ) : null}
<GoogleAuthButton onPress={signInWithGoogle} disabled={isBusy} loading={socialLoading} /> <GoogleAuthButton
onPress={() => requireTermsAcceptance(signInWithGoogle)}
disabled={isBusy}
loading={socialLoading}
/>
</View> </View>
<View style={styles.footer}> <View style={styles.footer}>
<Pressable
accessibilityRole="button"
onPress={openRegistrationTerms}
style={styles.termsAcceptance}
>
<View
style={[styles.termsIndicator, termsAccepted ? styles.termsIndicatorAccepted : null]}
>
{termsAccepted ? <View style={styles.termsIndicatorDot} /> : null}
</View>
<Text style={styles.terms}> <Text style={styles.terms}>
En tinscrivant, tu acceptes nos conditions générales{'\n'} {termsAccepted ? 'CGU et CGV acceptées' : 'Lire et accepter les CGU et CGV'}
dutilisation et notre politique de confidentialité. </Text>
</Pressable>
<Text style={styles.privacyText}>
En tinscrivant, tu acceptes notre politique de confidentialité.
</Text> </Text>
</View> </View>
<Text style={styles.bottomFooterText}> <Text style={styles.bottomFooterText}>
@@ -221,19 +289,29 @@ const Register = () => {
return ( return (
<Page <Page
width={isWeb ? 600 : null} width={isWeb ? '100%' : null}
maxWidth={isWeb ? 640 : 1200}
backgroundImg={isWeb ? background.loginBgWeb : background.homeBG} backgroundImg={isWeb ? background.loginBgWeb : background.homeBG}
headerType="NAVIGATION" headerType="NAVIGATION"
title="Inscription" title="Inscription"
onBackPressed={handleBack}
containerStyle={isCompactWeb ? styles.compactWebPage : null}
> >
<View style={styles.pageContent}> <View style={styles.pageContent}>
<ItemContainer <ItemContainer
height={isWeb ? 720 : undefined} height={webFormHeight}
disableKeyboardHeight disableKeyboardHeight
style={!isWeb ? styles.mobileItemContainer : styles.webItemContainer} style={!isWeb ? styles.mobileItemContainer : styles.webItemContainer}
> >
{isWeb ? ( {isWeb ? (
renderFormContent() <KeyboardAwareScrollView
style={styles.webScroll}
contentContainerStyle={styles.webScrollContent}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
>
{renderFormContent()}
</KeyboardAwareScrollView>
) : ( ) : (
<KeyboardAvoidingView <KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
@@ -334,7 +412,19 @@ const styles = StyleSheet.create({
}, },
webItemContainer: { webItemContainer: {
width: '100%', width: '100%',
maxWidth: 680, maxWidth: 600,
},
compactWebPage: {
paddingHorizontal: 12,
paddingTop: 8,
},
webScroll: {
flex: 1,
},
webScrollContent: {
flexGrow: 1,
justifyContent: 'center',
paddingVertical: 16,
}, },
mobileItemContainer: { mobileItemContainer: {
width: '100%', width: '100%',
@@ -353,11 +443,19 @@ const styles = StyleSheet.create({
paddingTop: 5, paddingTop: 5,
paddingHorizontal: 5, paddingHorizontal: 5,
}, },
compactFormContent: {
gap: 20,
paddingHorizontal: 10,
},
title: { title: {
fontSize: 22, fontSize: 22,
color: Palette.white, color: Palette.white,
fontFamily: FONT_FAMILY.InterSemiBold, fontFamily: FONT_FAMILY.InterSemiBold,
}, },
compactTitle: {
fontSize: 20,
textAlign: 'center',
},
languageInfo: { languageInfo: {
fontSize: 14, fontSize: 14,
color: Palette.gray, color: Palette.gray,
@@ -369,12 +467,42 @@ const styles = StyleSheet.create({
}, },
footer: { footer: {
alignItems: 'center', alignItems: 'center',
gap: 4, gap: 8,
},
termsAcceptance: {
alignItems: 'center',
flexDirection: 'row',
gap: 8,
},
termsIndicator: {
alignItems: 'center',
borderColor: Palette.white,
borderRadius: 100,
borderWidth: 1,
height: 17,
justifyContent: 'center',
width: 17,
},
termsIndicatorAccepted: {
borderColor: Palette.green,
},
termsIndicatorDot: {
backgroundColor: Palette.green,
borderRadius: 100,
height: 9,
width: 9,
}, },
terms: { terms: {
fontSize: 12, fontSize: 14,
color: Palette.white,
fontFamily: FONT_FAMILY.HelveticaNeueRegular,
textAlign: 'center',
textDecorationLine: 'underline',
},
privacyText: {
color: Palette.gray, color: Palette.gray,
fontFamily: FONT_FAMILY.HelveticaNeueRegular, fontFamily: FONT_FAMILY.HelveticaNeueRegular,
fontSize: 12,
textAlign: 'center', textAlign: 'center',
}, },
loginText: { loginText: {
+124
View File
@@ -0,0 +1,124 @@
import { CommonActions } from '@react-navigation/native'
import React, { useCallback, useState } from 'react'
import { ScrollView, StyleSheet, Text, View } from 'react-native'
import { background } from '../assets'
import GradientButton from '../components/GradientButton'
import { isWeb } from '../hooks/useLayoutType'
import Page from '../layouts/Page'
import { Palette } from '../styles'
import { FONT_FAMILY } from '../styles/Fonts'
import { termsOfUse } from './TermsOfUse'
const RegistrationTerms = ({ navigation, route }) => {
const [hasReachedEnd, setHasReachedEnd] = useState(false)
const sourceRouteKey = route.params?.sourceRouteKey
const updateScrollStatus = useCallback(({ nativeEvent }) => {
const { contentOffset, contentSize, layoutMeasurement } = nativeEvent
const endThreshold = 20
const reachedEnd =
contentSize.height <= layoutMeasurement.height + 1 ||
layoutMeasurement.height + contentOffset.y >= contentSize.height - endThreshold
if (reachedEnd) {
setHasReachedEnd(true)
}
}, [])
const handleAccept = useCallback(() => {
if (!hasReachedEnd || !sourceRouteKey) return
navigation.dispatch({
...CommonActions.setParams({ termsAccepted: true }),
source: sourceRouteKey,
})
navigation.goBack()
}, [hasReachedEnd, navigation, sourceRouteKey])
return (
<Page
width={isWeb ? 760 : null}
maxWidth={760}
backgroundImg={isWeb ? background.loginBgWeb : background.homeBG}
headerType="NAVIGATION"
title="CGU et CGV"
>
<View style={styles.container}>
<Text style={styles.introduction}>
Lis les conditions générales jusqu&apos;en bas pour pouvoir les accepter.
</Text>
<View style={styles.documentContainer}>
<ScrollView
contentContainerStyle={styles.documentContent}
onScroll={updateScrollStatus}
onMomentumScrollEnd={updateScrollStatus}
scrollEventThrottle={16}
showsVerticalScrollIndicator
>
<Text selectable style={styles.documentText}>
{termsOfUse}
</Text>
</ScrollView>
</View>
<Text style={styles.scrollHint}>
{hasReachedEnd
? 'Tu peux maintenant accepter les conditions.'
: 'Fais défiler le document jusquen bas pour continuer.'}
</Text>
<GradientButton
title="Jaccepte les CGU et CGV"
onPress={handleAccept}
disabled={!hasReachedEnd}
containerStyle={styles.acceptButton}
/>
</View>
</Page>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
gap: 16,
paddingVertical: 16,
},
introduction: {
color: Palette.gray,
fontFamily: FONT_FAMILY.InterRegular,
fontSize: 14,
lineHeight: 20,
textAlign: 'center',
},
documentContainer: {
flex: 1,
minHeight: 220,
overflow: 'hidden',
borderColor: Palette.ultraLightWhite,
borderRadius: 16,
borderWidth: 1,
backgroundColor: Palette.lightPurple,
},
documentContent: {
padding: 20,
},
documentText: {
color: Palette.gray,
fontFamily: FONT_FAMILY.InterRegular,
fontSize: 14,
lineHeight: 21,
},
scrollHint: {
color: Palette.gray,
fontFamily: FONT_FAMILY.InterRegular,
fontSize: 12,
textAlign: 'center',
},
acceptButton: {
alignSelf: 'center',
width: '100%',
maxWidth: 420,
},
})
export default RegistrationTerms
+1 -9
View File
@@ -36,15 +36,7 @@ export default ({ navigation }) => {
} finally { } finally {
navigation.reset({ navigation.reset({
index: 0, index: 0,
routes: [ routes: [{ name: Routes.LandingPage }],
{
name: Routes.BottomTab,
params: {
screen: Routes.HomeStack,
params: { screen: Routes.Home },
},
},
],
}) })
setIsFullyLoaded(true) setIsFullyLoaded(true)
} }
+9
View File
@@ -144,10 +144,19 @@ const ComposeSong = () => {
}, },
musicStatus: null, musicStatus: null,
sunoTaskId: firebase.firestore.FieldValue.delete(), sunoTaskId: firebase.firestore.FieldValue.delete(),
musicCreditsRefunded: firebase.firestore.FieldValue.delete(),
musicCreditsRefundOrderId: firebase.firestore.FieldValue.delete(),
} }
if (!isRegenerationFlow) { if (!isRegenerationFlow) {
payload.musicUrls = firebase.firestore.FieldValue.delete() payload.musicUrls = firebase.firestore.FieldValue.delete()
payload.musicAudioIds = firebase.firestore.FieldValue.delete()
payload.musicDurations = firebase.firestore.FieldValue.delete()
payload.musicTaskIds = firebase.firestore.FieldValue.delete()
payload.musicTimestamps = firebase.firestore.FieldValue.delete()
payload.lyricsSyncStatus = firebase.firestore.FieldValue.delete()
payload.lyricsSyncError = firebase.firestore.FieldValue.delete()
payload.lyricsSyncDiagnostics = firebase.firestore.FieldValue.delete()
} }
await updateProjectData(payload) await updateProjectData(payload)
+9 -5
View File
@@ -200,10 +200,19 @@ const ComposeSong = () => {
}, },
musicStatus: null, musicStatus: null,
sunoTaskId: firebase.firestore.FieldValue.delete(), sunoTaskId: firebase.firestore.FieldValue.delete(),
musicCreditsRefunded: firebase.firestore.FieldValue.delete(),
musicCreditsRefundOrderId: firebase.firestore.FieldValue.delete(),
} }
if (!isRegenerationFlow) { if (!isRegenerationFlow) {
payload.musicUrls = firebase.firestore.FieldValue.delete() payload.musicUrls = firebase.firestore.FieldValue.delete()
payload.musicAudioIds = firebase.firestore.FieldValue.delete()
payload.musicDurations = firebase.firestore.FieldValue.delete()
payload.musicTaskIds = firebase.firestore.FieldValue.delete()
payload.musicTimestamps = firebase.firestore.FieldValue.delete()
payload.lyricsSyncStatus = firebase.firestore.FieldValue.delete()
payload.lyricsSyncError = firebase.firestore.FieldValue.delete()
payload.lyricsSyncDiagnostics = firebase.firestore.FieldValue.delete()
} }
await updateProjectData(payload) await updateProjectData(payload)
@@ -307,7 +316,6 @@ const ComposeSong = () => {
) : null ) : null
} }
coinBadgeContainerStyle={styles.rightSideCredits} coinBadgeContainerStyle={styles.rightSideCredits}
containerStyle={styles.videoQuestionPage}
headerType="NONE" headerType="NONE"
> >
<MusicLandHeader <MusicLandHeader
@@ -502,10 +510,6 @@ const ComposeSong = () => {
export default ComposeSong export default ComposeSong
const styles = StyleSheet.create({ const styles = StyleSheet.create({
videoQuestionPage: {
alignSelf: 'flex-start',
marginLeft: '4%',
},
rightSideControl: { rightSideControl: {
right: 24, right: 24,
left: 'auto', left: 'auto',
+25 -3
View File
@@ -118,12 +118,20 @@ const SongReady = () => {
await updateProjectData({ await updateProjectData({
songIndex: selectedIndex, songIndex: selectedIndex,
songUrl: url, songUrl: url,
sunoAudioId: selectedProject?.musicAudioIds?.[selectedIndex] || null,
songSunoTaskId: selectedProject?.musicTaskIds?.[selectedIndex] || null,
lyricsSyncStatus: 'PENDING',
lyricsSyncError: null,
cover: null, cover: null,
coverUrl: null, coverUrl: null,
coverStatus: null, coverStatus: null,
playbackUrl: null, playbackUrl: null,
playbackStatus: null, playbackStatus: null,
playbackGenerating: null, playbackGenerating: null,
songPublishedOnMusicLand: false,
songPublishedOnMusicLandAt: null,
playbackPublishedOnMusicLand: false,
playbackPublishedOnMusicLandAt: null,
}) })
// Incrémenter le compteur de chansons générées // Incrémenter le compteur de chansons générées
@@ -289,6 +297,7 @@ const SongReady = () => {
onTogglePlayback={handleTogglePlayback} onTogglePlayback={handleTogglePlayback}
projectId={projectId} projectId={projectId}
selectedProject={selectedProject} selectedProject={selectedProject}
fallbackDurationS={selectedProject?.musicDurations?.[index]}
/> />
)} )}
contentContainerStyle={{ gap: 16, paddingBottom: gutters }} contentContainerStyle={{ gap: 16, paddingBottom: gutters }}
@@ -338,6 +347,7 @@ const SongOptionCard = ({
onTogglePlayback, onTogglePlayback,
projectId, projectId,
selectedProject, selectedProject,
fallbackDurationS,
}) => { }) => {
const trackTitle = const trackTitle =
(Array.isArray(selectedProject?.musicTitles) ? selectedProject.musicTitles?.[index] : null) || (Array.isArray(selectedProject?.musicTitles) ? selectedProject.musicTitles?.[index] : null) ||
@@ -352,9 +362,20 @@ const SongOptionCard = ({
metadata: { index, projectId }, metadata: { index, projectId },
}) })
const [progressInfo, setProgressInfo] = useState({ pos: 0, dur: 0 }) const fallbackDurationMs = Math.max(0, Math.round((Number(fallbackDurationS) || 0) * 1000))
const [progressInfo, setProgressInfo] = useState({
pos: 0,
dur: fallbackDurationMs,
})
const [isPlaying, setIsPlaying] = useState(false) const [isPlaying, setIsPlaying] = useState(false)
useEffect(() => {
if (!fallbackDurationMs) return
setProgressInfo((current) =>
current.dur ? current : { ...current, dur: fallbackDurationMs }
)
}, [fallbackDurationMs])
useEffect(() => { useEffect(() => {
registerPlayer(index, player) registerPlayer(index, player)
return () => registerPlayer(index, null) return () => registerPlayer(index, null)
@@ -379,7 +400,8 @@ const SongOptionCard = ({
return undefined return undefined
} }
const id = setInterval(() => { const id = setInterval(() => {
const durationMs = Math.max(0, Math.round((Number(player.duration) || 0) * 1000)) const playerDurationMs = Math.max(0, Math.round((Number(player.duration) || 0) * 1000))
const durationMs = playerDurationMs || fallbackDurationMs
const positionMs = Math.max(0, Math.round((Number(player.currentTime) || 0) * 1000)) const positionMs = Math.max(0, Math.round((Number(player.currentTime) || 0) * 1000))
setProgressInfo((prev) => { setProgressInfo((prev) => {
if ( if (
@@ -399,7 +421,7 @@ const SongOptionCard = ({
}) })
}, 300) }, 300)
return () => clearInterval(id) return () => clearInterval(id)
}, [player]) }, [fallbackDurationMs, player])
const handleSeek = useCallback( const handleSeek = useCallback(
async (targetMs) => { async (targetMs) => {
+11 -11
View File
@@ -5,7 +5,7 @@ import Page from '../layouts/Page'
import { Fonts } from '../styles' import { Fonts } from '../styles'
const terms = `Conditions Générales d'Utilisation (CGU) de minuit.starter export const termsOfUse = `Conditions Générales d'Utilisation (CGU) de MusicLand
Édition par MINUIT.AGENCY, Édition par MINUIT.AGENCY,
44 RUE MINVIELLE, 33000 BORDEAUX, 44 RUE MINVIELLE, 33000 BORDEAUX,
@@ -15,16 +15,16 @@ Représenté par minuit.holdings, Président.
Définitions : Définitions :
Utilisateur : toute personne physique ou morale qui utilise l'application minuit.starter. Utilisateur : toute personne physique ou morale qui utilise l'application MusicLand.
Contenu Utilisateur : toute information, texte, image, ou autre matériel que les utilisateurs publient ou partagent sur minuit.starter. Contenu Utilisateur : toute information, texte, image, ou autre matériel que les utilisateurs publient ou partagent sur MusicLand.
Minuit.boost : service prioritaire commandé par les utilisateurs sur minuit.starter pour accélérer le processus de réalisation de leurs tâches. Minuit.boost : service prioritaire commandé par les utilisateurs sur MusicLand pour accélérer le processus de réalisation de leurs tâches.
Tâche Supplémentaire : toute tâche additionnelle commandée par les utilisateurs sur minuit.starter. Tâche Supplémentaire : toute tâche additionnelle commandée par les utilisateurs sur MusicLand.
Minuit.coin : unité de monnaie virtuelle utilisée sur minuit.starter pour acheter des services ou des tâches supplémentaires. Minuit.coin : unité de monnaie virtuelle utilisée sur MusicLand pour acheter des services ou des tâches supplémentaires.
Acceptation des CGU et CGV Acceptation des CGU et CGV
L'utilisation de "minuit.starter" et/ou le téléchargement de l'application sur l'App Store iOS implique l'acceptation inconditionnelle de ces CGU. Tout achat de services ou de tâches supplémentaires sur la plateforme implique également l'acceptation des Conditions Générales de Vente (CGV). Si vous n'êtes pas d'accord avec l'une de ces conditions, vous devez immédiatement cesser toute utilisation de l'application. L'utilisation de "MusicLand" et/ou le téléchargement de l'application sur l'App Store iOS implique l'acceptation inconditionnelle de ces CGU. Tout achat de services ou de tâches supplémentaires sur la plateforme implique également l'acceptation des Conditions Générales de Vente (CGV). Si vous n'êtes pas d'accord avec l'une de ces conditions, vous devez immédiatement cesser toute utilisation de l'application.
Objet Objet
"minuit.starter" fournit des outils pour aider les utilisateurs à publier des tâches, suivre l'évolution de leurs projets, commander des Minuit.boosts pour un service prioritaire, commander des tâches supplémentaires, et acheter des minuit.coins pour utiliser les services payants proposés sur la plateforme. "MusicLand" fournit des outils pour aider les utilisateurs à publier des tâches, suivre l'évolution de leurs projets, commander des Minuit.boosts pour un service prioritaire, commander des tâches supplémentaires, et acheter des minuit.coins pour utiliser les services payants proposés sur la plateforme.
Fonctionnement AI de l'Application Fonctionnement AI de l'Application
Toutes les informations, notamment les délais, fournies par l'application sont basées sur des algorithmes d'intelligence artificielle. Elles sont fournies "telles quelles", sans garantie d'exactitude, d'exhaustivité ou de pertinence pour des situations spécifiques. Les réponses du chat AI sont à titre informatif et ne doivent pas remplacer un avis professionnel. Toutes les informations, notamment les délais, fournies par l'application sont basées sur des algorithmes d'intelligence artificielle. Elles sont fournies "telles quelles", sans garantie d'exactitude, d'exhaustivité ou de pertinence pour des situations spécifiques. Les réponses du chat AI sont à titre informatif et ne doivent pas remplacer un avis professionnel.
@@ -33,13 +33,13 @@ Accès & Utilisation
Seuls les individus majeurs et capables juridiquement peuvent utiliser l'application. Toute utilisation illégale, abusive ou en violation des présentes CGU est interdite. Seuls les individus majeurs et capables juridiquement peuvent utiliser l'application. Toute utilisation illégale, abusive ou en violation des présentes CGU est interdite.
Disponibilité & Interruption Disponibilité & Interruption
L'accès à minuit.starter peut être interrompu, suspendu ou modifié à tout moment, pour n'importe quelle raison, sans préavis ni obligation. MINUIT.AGENCY ne sera pas responsable des pertes de données ou des interruptions de service. L'accès à MusicLand peut être interrompu, suspendu ou modifié à tout moment, pour n'importe quelle raison, sans préavis ni obligation. MINUIT.AGENCY ne sera pas responsable des pertes de données ou des interruptions de service.
Confidentialité Confidentialité
Nous traitons les données personnelles conformément à notre politique de confidentialité, que les utilisateurs doivent consulter séparément. Nous traitons les données personnelles conformément à notre politique de confidentialité, que les utilisateurs doivent consulter séparément.
Responsabilité des Informations et Contenu Utilisateur Responsabilité des Informations et Contenu Utilisateur
MINUIT.AGENCY ne garantit pas la véracité, la précision, ou la fiabilité des informations fournies par l'application. minuit.starter est une plateforme sur laquelle les utilisateurs peuvent publier du contenu. Malgré nos meilleurs efforts pour maintenir un environnement sûr et respectueux, MINUIT.AGENCY n'est pas responsable du contenu publié par les utilisateurs. Tout contenu publié reflète uniquement l'opinion de l'utilisateur qui le publie. MINUIT.AGENCY ne garantit pas la véracité, la précision, ou la fiabilité des informations fournies par l'application. MusicLand est une plateforme sur laquelle les utilisateurs peuvent publier du contenu. Malgré nos meilleurs efforts pour maintenir un environnement sûr et respectueux, MINUIT.AGENCY n'est pas responsable du contenu publié par les utilisateurs. Tout contenu publié reflète uniquement l'opinion de l'utilisateur qui le publie.
Propriété Intellectuelle Propriété Intellectuelle
Toute violation des droits de propriété intellectuelle de MINUIT.AGENCY pourra donner lieu à des poursuites judiciaires. Toute violation des droits de propriété intellectuelle de MINUIT.AGENCY pourra donner lieu à des poursuites judiciaires.
@@ -67,7 +67,7 @@ Dernière mise à jour : 22/10/2023.`
export default ({ navigation, route }) => { export default ({ navigation, route }) => {
return ( return (
<Page headerType="NAVIGATE" title={"Conditions Générales d'Utilisation"} scrollEnabled> <Page headerType="NAVIGATE" title={"Conditions Générales d'Utilisation"} scrollEnabled>
<Text style={{ ...Fonts({ type: 'default' }) }}>{terms}</Text> <Text style={{ ...Fonts({ type: 'default' }) }}>{termsOfUse}</Text>
</Page> </Page>
) )
} }
+3 -16
View File
@@ -1,8 +1,7 @@
import React, { useMemo, useState } from 'react' 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 { responsiveHeight } from 'react-native-responsive-dimensions'
import { background } from '../../assets' import { background } from '../../assets'
import BackgroundVideo from '../../components/BackgroundVideo'
import GradientButton from '../../components/GradientButton' import GradientButton from '../../components/GradientButton'
import MusicLandHeader from '../../components/MusicLandHeader' import MusicLandHeader from '../../components/MusicLandHeader'
import { OTHER_OBJECTIVE_OPTION, OTHER_STYLE_OPTION } from '../../data/data' 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 CUSTOM_SANITIZE_OPTIONS = { autoInjectPreChorus: false }
const CreateLyricsWithAi = () => { const CreateLyricsWithAi = () => {
const { selectedProject, videos } = useUser() const { selectedProject } = useUser()
const [selectedIndex, setSelectedIndex] = useState(0) const [selectedIndex, setSelectedIndex] = useState(0)
const handleLyricsError = React.useCallback(() => { const handleLyricsError = React.useCallback(() => {
setSelectedIndex(7) setSelectedIndex(7)
@@ -386,18 +385,13 @@ const CreateLyricsWithAi = () => {
] ]
const currentStep = steps[selectedIndex] const currentStep = steps[selectedIndex]
const isQuestionStep = selectedIndex < MAX_STEP_INDEX
return ( return (
<Page <Page
headerType="NONE" headerType="NONE"
behavior={Platform.OS === 'ios' ? 'padding' : 'height'} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
keyboardVerticalOffset={Platform.OS === 'ios' ? 64 : 100} keyboardVerticalOffset={Platform.OS === 'ios' ? 64 : 100}
backgroundImg={isQuestionStep ? null : background.libraryBgWeb} backgroundImg={background.libraryBgWeb}
backgroundContent={
isQuestionStep ? <BackgroundVideo source={videos?.celineWeb} /> : null
}
containerStyle={isQuestionStep ? styles.videoQuestionPage : undefined}
> >
<MusicLandHeader onPressBack={onPressBack} progress={progress} /> <MusicLandHeader onPressBack={onPressBack} progress={progress} />
<View <View
@@ -435,10 +429,3 @@ const CreateLyricsWithAi = () => {
} }
export default CreateLyricsWithAi export default CreateLyricsWithAi
const styles = StyleSheet.create({
videoQuestionPage: {
alignSelf: 'flex-end',
marginRight: '4%',
},
})
+3 -2
View File
@@ -5,7 +5,7 @@ import useMinuit from 'react-native-minuit/src/hooks/useMinuit.js'
import alert from '../../components/Alert' import alert from '../../components/Alert'
import GradientButton from '../../components/GradientButton' import GradientButton from '../../components/GradientButton'
import ProgressBar from '../../components/ProgressBar' import ProgressBar from '../../components/ProgressBar'
import firebase from '../../config/firebase' import { getFunctionsClient } from '../../config/firebase'
import { Routes } from '../../navigation' import { Routes } from '../../navigation'
import { navigate } from '../../navigation/NavigationService' import { navigate } from '../../navigation/NavigationService'
import { useUser } from '../../providers/UserDataProvider' import { useUser } from '../../providers/UserDataProvider'
@@ -20,6 +20,7 @@ import {
const FAKE_PROGRESS_MAX = 96 const FAKE_PROGRESS_MAX = 96
const PROGRESS_INTERVAL_MS = 250 const PROGRESS_INTERVAL_MS = 250
const CUSTOM_SANITIZE_OPTIONS = { autoInjectPreChorus: false } const CUSTOM_SANITIZE_OPTIONS = { autoInjectPreChorus: false }
const FUNCTIONS_REGION = 'europe-west1'
const CreatingLyrics = ({ active, config, selections, onErrorRedirect }) => { const CreatingLyrics = ({ active, config, selections, onErrorRedirect }) => {
const [progress, setProgress] = useState(0) const [progress, setProgress] = useState(0)
@@ -73,7 +74,7 @@ const CreatingLyrics = ({ active, config, selections, onErrorRedirect }) => {
platform: Platform.OS, platform: Platform.OS,
}) })
const sanitizedStructure = sanitizeStructureList(config?.structure, CUSTOM_SANITIZE_OPTIONS) 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({ const { data } = await callable({
lang: projectLang, lang: projectLang,
objective: config?.objective, objective: config?.objective,
+5 -2
View File
@@ -8,7 +8,7 @@ import GradientButton from '../../components/GradientButton'
import ItemContainer from '../../components/ItemContainer/ItemContainer' import ItemContainer from '../../components/ItemContainer/ItemContainer'
import MusicLandHeader from '../../components/MusicLandHeader' import MusicLandHeader from '../../components/MusicLandHeader'
import Overlay from '../../components/Overlay' import Overlay from '../../components/Overlay'
import firebase, { projectsRef } from '../../config/firebase' import firebase, { getFunctionsClient, projectsRef } from '../../config/firebase'
import { strings } from '../../constants/strings' import { strings } from '../../constants/strings'
import { isWeb } from '../../hooks/useLayoutType' import { isWeb } from '../../hooks/useLayoutType'
import Page from '../../layouts/Page' import Page from '../../layouts/Page'
@@ -31,6 +31,7 @@ import CustomInput from './components/CustomInput'
const RESPONSIBILITY_CHECKBOX_LABEL = 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.' '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 Lyrics = ({ navigation }) => {
const scrollRef = useRef(null) const scrollRef = useRef(null)
@@ -239,7 +240,9 @@ const Lyrics = ({ navigation }) => {
// 1) Appel de la Cloud Function de modération avant tout enregistrement // 1) Appel de la Cloud Function de modération avant tout enregistrement
try { try {
const callable = firebase.functions().httpsCallable('lyrics-analyseLyricsToxicity') const callable = getFunctionsClient(FUNCTIONS_REGION).httpsCallable(
'lyrics-analyseLyricsToxicity'
)
const { data } = await callable({ const { data } = await callable({
title: titleTrimmed, title: titleTrimmed,
lyrics: normalizedNewLyrics, lyrics: normalizedNewLyrics,
+199 -216
View File
@@ -1,22 +1,13 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Image as ExpoImage } from 'expo-image' import { Image as ExpoImage } from 'expo-image'
import { import { Linking, Platform, ScrollView, StyleSheet, Text, View, Alert } from 'react-native'
BackHandler,
Linking,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
Alert,
} from 'react-native'
import { useFocusEffect } from '@react-navigation/native'
import useMinuit from 'react-native-minuit/src/hooks/useMinuit.js' import useMinuit from 'react-native-minuit/src/hooks/useMinuit.js'
import * as FileSystem from 'expo-file-system' import * as FileSystem from 'expo-file-system'
import * as Sharing from 'expo-sharing' import * as Sharing from 'expo-sharing'
import BorderGradientButton from '../../components/BorderGradientButton'
import GradientButton from '../../components/GradientButton' import GradientButton from '../../components/GradientButton'
import MusicLandHeader from '../../components/MusicLandHeader' import MusicLandHeader from '../../components/MusicLandHeader'
import { projectsRef, serverTimestamp } from '../../config/firebase'
import Page from '../../layouts/Page' import Page from '../../layouts/Page'
import { Routes } from '../../navigation/Routes' import { Routes } from '../../navigation/Routes'
import { goBack, navigate, push } from '../../navigation/NavigationService' import { goBack, navigate, push } from '../../navigation/NavigationService'
@@ -27,15 +18,11 @@ import { FONT_FAMILY } from '../../styles/Fonts'
import CreateLyricsHeader from '../Writing/components/CreateLyricsHeader' import CreateLyricsHeader from '../Writing/components/CreateLyricsHeader'
import { background } from '../../assets' import { background } from '../../assets'
import { getStageAction } from '../../utils/projectStages' import { getStageAction } from '../../utils/projectStages'
import ClubAdvantagesCard from '../Profile/components/ClubAdvantagesCard'
import useGlobalLoading from '../../hooks/useGlobalLoading' import useGlobalLoading from '../../hooks/useGlobalLoading'
import { MaterialCommunityIcons } from '@expo/vector-icons'
import { isWeb } from '../../hooks/useLayoutType' import { isWeb } from '../../hooks/useLayoutType'
import { getArtistDisplayName } from '../../utils/artistName' import { getArtistDisplayName } from '../../utils/artistName'
import { toDate } from '../../utils/dateFormatting' import { toDate } from '../../utils/dateFormatting'
import FullscreenIntroVideo from '../../components/FullscreenIntroVideo' import FullscreenIntroVideo from '../../components/FullscreenIntroVideo'
import AdventureChoiceModal from './components/AdventureChoiceModal'
import ShareAndCreditsModal from './components/ShareAndCreditsModal'
import { musiclandShareHeading } from '../../data' import { musiclandShareHeading } from '../../data'
const SongDownload = ({ route }) => { const SongDownload = ({ route }) => {
@@ -54,16 +41,12 @@ const SongDownload = ({ route }) => {
const [isDownloading, setIsDownloading] = useState(false) const [isDownloading, setIsDownloading] = useState(false)
const [isCheckoutLaunching, setIsCheckoutLaunching] = useState(false) const [isCheckoutLaunching, setIsCheckoutLaunching] = useState(false)
const [downloadPaymentPending, setDownloadPaymentPending] = useState(false) const [downloadPaymentPending, setDownloadPaymentPending] = useState(false)
const [pendingDownloadIntent, setPendingDownloadIntent] = useState(null)
const [isPublishing, setIsPublishing] = useState(false)
const { videos } = useUser() const { videos } = useUser()
const part1Url = isWeb ? videos?.benaiPart1 : videos?.benaiPart1 const part1Url = isWeb ? videos?.benaiPart1 : videos?.benaiPart1
const [showIntro, setShowIntro] = useState(null) const [showIntro, setShowIntro] = useState(null)
const [showAdventureModal, setShowAdventureModal] = useState(false) const introTriggeredRef = useRef(false)
const [showShareModal, setShowShareModal] = useState(false)
const [adventureChoice, setAdventureChoice] = useState(() =>
skipAdventureGate ? 'stop' : 'pending'
)
const adventureGateTriggeredRef = useRef(false)
const reopenAdventureModalOnFocusRef = useRef(false)
const projectForStage = useMemo(() => { const projectForStage = useMemo(() => {
if (routeProject?.id && selectedProject?.id && routeProject.id === selectedProject.id) { if (routeProject?.id && selectedProject?.id && routeProject.id === selectedProject.id) {
@@ -110,84 +93,29 @@ const SongDownload = ({ route }) => {
const downloadPurchaseStatus = projectForStage?.downloadPurchase?.status || null const downloadPurchaseStatus = projectForStage?.downloadPurchase?.status || null
const hasPaidDownload = downloadPurchaseStatus === 'paid' const hasPaidDownload = downloadPurchaseStatus === 'paid'
const canDownloadDirectly = hasActiveSubscription || hasPaidDownload || hasPurchased const canDownloadDirectly = hasActiveSubscription || hasPaidDownload || hasPurchased
const downloadLabel = canDownloadDirectly const downloadAccessLabel = canDownloadDirectly
? 'Télécharger mon morceau' ? 'Téléchargement inclus avec ton accès actuel.'
: 'Télécharger ce morceau pour 1,99' : 'Le téléchargement de ce morceau coûte 1,99 €.'
const shouldShowDownloadPage = adventureChoice === 'stop'
const reopenAdventureChoice = useCallback(() => {
reopenAdventureModalOnFocusRef.current = false
setShowIntro(null)
setAdventureChoice('pending')
setShowAdventureModal(true)
}, [])
const handleBackPress = useCallback(() => { const handleBackPress = useCallback(() => {
if (shouldShowDownloadPage) {
reopenAdventureChoice()
return true
}
if (backRoute) { if (backRoute) {
navigate(backRoute) navigate(backRoute)
return true return true
} }
goBack() goBack()
return true return true
}, [backRoute, reopenAdventureChoice, shouldShowDownloadPage]) }, [backRoute])
useFocusEffect(
useCallback(() => {
if (Platform.OS !== 'android' || (!backRoute && !shouldShowDownloadPage)) {
return undefined
}
const subscription = BackHandler.addEventListener('hardwareBackPress', handleBackPress)
return () => subscription.remove()
}, [backRoute, handleBackPress, shouldShowDownloadPage])
)
useFocusEffect(
useCallback(() => {
if (!reopenAdventureModalOnFocusRef.current) {
return undefined
}
setShowIntro(null)
setShowAdventureModal(true)
return undefined
}, [])
)
useEffect(() => { useEffect(() => {
if (adventureGateTriggeredRef.current) { if (skipAdventureGate || introTriggeredRef.current) return
return
}
if (adventureChoice !== 'pending') {
return
}
if (typeof videos === 'undefined') { if (typeof videos === 'undefined') {
return return
} }
adventureGateTriggeredRef.current = true introTriggeredRef.current = true
if (part1Url) { if (part1Url) {
setShowIntro(part1Url) setShowIntro(part1Url)
} else {
setShowAdventureModal(true)
} }
}, [adventureChoice, part1Url, videos]) }, [part1Url, skipAdventureGate, videos])
useEffect(() => {
if (adventureChoice !== 'pending') {
return
}
if (!adventureGateTriggeredRef.current) {
return
}
if (showIntro) {
return
}
if (!showAdventureModal) {
setShowAdventureModal(true)
}
}, [adventureChoice, showAdventureModal, showIntro])
const continueFlow = useCallback(async () => { const continueFlow = useCallback(async () => {
if (!selectedOption) { if (!selectedOption) {
@@ -225,25 +153,13 @@ const SongDownload = ({ route }) => {
} finally { } finally {
await setLoading(false) await setLoading(false)
} }
}, [ }, [coverOptions, coverUrl, projectForStage, push, selectedOption, setLoading, updateProjectData])
coverOptions,
coverUrl,
projectForStage,
push,
selectedOption,
setLoading,
updateProjectData,
])
const handleCloseIntro = useCallback(() => { const handleCloseIntro = useCallback(() => {
setShowIntro(null) setShowIntro(null)
setShowAdventureModal(true)
}, []) }, [])
const handleContinueAdventure = useCallback(() => { const handleContinueAdventure = useCallback(() => {
reopenAdventureModalOnFocusRef.current = true
setShowAdventureModal(false)
setAdventureChoice('continue')
continueFlow() continueFlow()
}, [continueFlow]) }, [continueFlow])
@@ -255,7 +171,7 @@ const SongDownload = ({ route }) => {
selectedOption?.generatedUrl || selectedOption?.generatedUrl ||
null null
if (!downloadUrl || isDownloading) { if (!downloadUrl || isDownloading) {
return return false
} }
await setLoading(true, { message: 'Préparation du téléchargement...' }) await setLoading(true, { message: 'Préparation du téléchargement...' })
@@ -390,12 +306,22 @@ const SongDownload = ({ route }) => {
} }
if (isWeb) { if (isWeb) {
try {
await triggerWebDownload(downloadUrl, projectForStage?.title) await triggerWebDownload(downloadUrl, projectForStage?.title)
return true
} catch (error) {
setTooltip({
type: 'error',
text: error?.message || 'Impossible de télécharger le morceau.',
})
return false
} finally {
await setLoading(false) await setLoading(false)
return }
} }
setIsDownloading(true) setIsDownloading(true)
let didDownload = false
try { try {
const baseName = const baseName =
String(trackTitle || 'musicland-track') String(trackTitle || 'musicland-track')
@@ -427,6 +353,7 @@ const SongDownload = ({ route }) => {
await FileSystem.writeAsStringAsync(destUri, base64, { await FileSystem.writeAsStringAsync(destUri, base64, {
encoding: FileSystem.EncodingType.Base64, encoding: FileSystem.EncodingType.Base64,
}) })
didDownload = true
} catch (error) { } catch (error) {
console.log('[SongDownload] SAF write error', error?.message) console.log('[SongDownload] SAF write error', error?.message)
} }
@@ -437,12 +364,14 @@ const SongDownload = ({ route }) => {
mimeType: 'audio/mpeg', mimeType: 'audio/mpeg',
dialogTitle: musiclandShareHeading, dialogTitle: musiclandShareHeading,
}) })
didDownload = true
} catch (error) { } catch (error) {
console.log('[SongDownload] share error', error?.message) console.log('[SongDownload] share error', error?.message)
} }
} else { } else {
try { try {
await Linking.openURL(downloadResult.uri) await Linking.openURL(downloadResult.uri)
didDownload = true
} catch (error) { } catch (error) {
console.log('[SongDownload] open local file error', error?.message) console.log('[SongDownload] open local file error', error?.message)
} }
@@ -454,10 +383,60 @@ const SongDownload = ({ route }) => {
setIsDownloading(false) setIsDownloading(false)
await setLoading(false) await setLoading(false)
} }
}, [coverUrl, isDownloading, projectForStage, selectedOption, trackTitle]) return didDownload
}, [coverUrl, isDownloading, projectForStage, selectedOption, setLoading, setTooltip, trackTitle])
const executeDownloadChoice = useCallback(
async (intent) => {
if (intent !== 'personal' && intent !== 'publish') return
try {
setIsPublishing(intent === 'publish')
const didDownload = await handleDownload()
if (!didDownload) {
setTooltip({
type: 'error',
text: "Le téléchargement du morceau n'a pas abouti.",
})
return
}
if (intent === 'personal') {
setTooltip({
type: 'success',
text: 'Morceau téléchargé à des fins personnelles.',
})
return
}
if (!projectId) {
throw new Error('Projet introuvable pour la publication.')
}
await projectsRef.doc(projectId).set(
{
songPublishedOnMusicLand: true,
songPublishedOnMusicLandAt: serverTimestamp(),
},
{ merge: true }
)
setTooltip({
type: 'success',
text: 'Morceau téléchargé et publié sur MusicLand !',
})
} catch (error) {
setTooltip({
type: 'error',
text: error?.message || "Impossible de terminer l'opération.",
})
} finally {
setIsPublishing(false)
}
},
[handleDownload, projectId, setTooltip]
)
const startSongDownloadCheckout = useCallback( const startSongDownloadCheckout = useCallback(
async ({ force = false } = {}) => { async (intent, { force = false } = {}) => {
if (!projectId) { if (!projectId) {
if (setTooltip) { if (setTooltip) {
setTooltip({ setTooltip({
@@ -474,39 +453,36 @@ const SongDownload = ({ route }) => {
return return
} }
setPendingDownloadIntent(intent)
setIsCheckoutLaunching(true) setIsCheckoutLaunching(true)
setDownloadPaymentPending(true) setDownloadPaymentPending(true)
try { try {
await createSongDownloadCheckout(projectId) await createSongDownloadCheckout(projectId)
} catch (error) { } catch (error) {
setDownloadPaymentPending(false) setDownloadPaymentPending(false)
setPendingDownloadIntent(null)
if (setTooltip) { if (setTooltip) {
setTooltip({ setTooltip({
type: 'error', type: 'error',
text: error?.message || "Une erreur est survenue lors du paiement.", text: error?.message || 'Une erreur est survenue lors du paiement.',
}) })
} else { } else {
Alert.alert('Paiement', error?.message || "Une erreur est survenue lors du paiement.") Alert.alert('Paiement', error?.message || 'Une erreur est survenue lors du paiement.')
} }
} finally { } finally {
setIsCheckoutLaunching(false) setIsCheckoutLaunching(false)
} }
}, },
[ [createSongDownloadCheckout, downloadPaymentPending, isCheckoutLaunching, projectId, setTooltip]
createSongDownloadCheckout,
downloadPaymentPending,
isCheckoutLaunching,
projectId,
setTooltip,
]
) )
const handleDownloadPress = useCallback(() => { const handleDownloadChoice = useCallback(
if (isDownloading || isCheckoutLaunching) { (intent) => {
if (isDownloading || isCheckoutLaunching || isPublishing) {
return return
} }
if (canDownloadDirectly) { if (canDownloadDirectly) {
handleDownload() executeDownloadChoice(intent)
return return
} }
if (downloadPaymentPending) { if (downloadPaymentPending) {
@@ -515,7 +491,10 @@ const SongDownload = ({ route }) => {
"Le paiement n'est pas encore confirmé. Si tu as déjà payé, patiente quelques instants.", "Le paiement n'est pas encore confirmé. Si tu as déjà payé, patiente quelques instants.",
[ [
{ text: 'Attendre', style: 'cancel' }, { text: 'Attendre', style: 'cancel' },
{ text: 'Relancer le paiement', onPress: () => startSongDownloadCheckout({ force: true }) }, {
text: 'Relancer le paiement',
onPress: () => startSongDownloadCheckout(intent, { force: true }),
},
] ]
) )
return return
@@ -526,50 +505,45 @@ const SongDownload = ({ route }) => {
'Voulez-vous télécharger ce morceau pour 1,99€ ?', 'Voulez-vous télécharger ce morceau pour 1,99€ ?',
[ [
{ text: 'Annuler', style: 'cancel' }, { text: 'Annuler', style: 'cancel' },
{ text: 'Acheter', onPress: () => startSongDownloadCheckout() }, { text: 'Acheter', onPress: () => startSongDownloadCheckout(intent) },
], ],
{ cancelable: true } { cancelable: true }
) )
return return
} }
startSongDownloadCheckout() startSongDownloadCheckout(intent)
}, [ },
[
canDownloadDirectly, canDownloadDirectly,
downloadPaymentPending, downloadPaymentPending,
handleDownload, executeDownloadChoice,
isCheckoutLaunching, isCheckoutLaunching,
isDownloading, isDownloading,
isPublishing,
promptPurchaseConfirm, promptPurchaseConfirm,
startSongDownloadCheckout, startSongDownloadCheckout,
]) ]
)
useEffect(() => { useEffect(() => {
if (!downloadPaymentPending || !hasPaidDownload || isDownloading) { if (!downloadPaymentPending || !hasPaidDownload || !pendingDownloadIntent || isDownloading) {
return return
} }
const intent = pendingDownloadIntent
setDownloadPaymentPending(false) setDownloadPaymentPending(false)
handleDownload() setPendingDownloadIntent(null)
}, [downloadPaymentPending, handleDownload, hasPaidDownload, isDownloading]) executeDownloadChoice(intent)
}, [
const handleStopAdventure = useCallback(() => { downloadPaymentPending,
reopenAdventureModalOnFocusRef.current = false executeDownloadChoice,
setShowAdventureModal(false) hasPaidDownload,
setAdventureChoice('stop') isDownloading,
}, []) pendingDownloadIntent,
])
const handleGoHome = useCallback(() => {
navigate(Routes.BottomTab, {
screen: Routes.HomeStack,
params: {
screen: Routes.Home,
},
})
}, [])
return ( return (
<Page backgroundImg={background.studioBG2} headerType="NONE"> <Page backgroundImg={background.studioBG2} headerType="NONE">
<MusicLandHeader onPressBack={handleBackPress} progress={84} /> <MusicLandHeader onPressBack={handleBackPress} progress={84} />
{shouldShowDownloadPage ? (
<ScrollView <ScrollView
contentContainerStyle={[ contentContainerStyle={[
styles.container, styles.container,
@@ -578,11 +552,12 @@ const SongDownload = ({ route }) => {
]} ]}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
> >
<CreateLyricsHeader title="Ta pochette est validée" /> <CreateLyricsHeader
<ClubAdvantagesCard title="Ton morceau est prêt"
style={styles.clubCardSpacing} subTitle="Choisis ce que tu souhaites faire maintenant."
topContent={ />
<View style={styles.coverRow}>
<View style={styles.summaryCard}>
{coverUrl ? ( {coverUrl ? (
<ExpoImage source={{ uri: coverUrl }} style={styles.coverImage} contentFit="cover" /> <ExpoImage source={{ uri: coverUrl }} style={styles.coverImage} contentFit="cover" />
) : ( ) : (
@@ -590,54 +565,52 @@ const SongDownload = ({ route }) => {
<Text style={styles.placeholderText}>Aucune pochette</Text> <Text style={styles.placeholderText}>Aucune pochette</Text>
</View> </View>
)} )}
<Text style={styles.trackTitle}>{trackTitle}</Text>
</View>
<View style={styles.downloadColumn}> <View style={styles.choiceSection}>
<Pressable <Text style={styles.sectionTitle}>Téléchargement</Text>
style={[ <Text style={styles.sectionDescription}>{downloadAccessLabel}</Text>
styles.downloadTile, <BorderGradientButton
(isDownloading || isCheckoutLaunching) && { opacity: 0.6 }, title="Option 1 · Téléchargement à des fins personnelles"
]} onPress={() => handleDownloadChoice('personal')}
onPress={handleDownloadPress} disabled={isDownloading || isCheckoutLaunching || isPublishing}
disabled={isDownloading || isCheckoutLaunching} loading={isDownloading && !isPublishing}
> loadingText="Téléchargement en cours..."
<MaterialCommunityIcons name="download" size={22} color={Palette.white} /> height={58}
<Text style={styles.downloadText}>{downloadLabel}</Text> titleStyle={styles.choiceButtonText}
</Pressable>
{!canDownloadDirectly && downloadPaymentPending ? (
<Text style={styles.downloadPendingText}>
Paiement en attente de confirmation...
</Text>
) : null}
</View>
</View>
}
/> />
<GradientButton <GradientButton
title="Revenir à l'accueil" title={
onPress={handleGoHome} isPublishing
containerStyle={styles.returnHomeButton} ? 'Téléchargement et publication en cours...'
: 'Option 2 · Télécharger et publier sur MusicLand'
}
onPress={() => handleDownloadChoice('publish')}
disabled={isDownloading || isCheckoutLaunching || isPublishing}
height={58}
textStyle={styles.choiceButtonText}
/> />
{!canDownloadDirectly && downloadPaymentPending ? (
<Text style={styles.downloadPendingText}>Paiement en attente de confirmation...</Text>
) : null}
</View>
<View style={styles.continueSection}>
<Text style={styles.sectionTitle}>Continuer l'aventure</Text>
<Text style={styles.sectionDescription}>
Retrouve Théo dans l'espace Playback pour donner vie à ton morceau en vidéo.
</Text>
<BorderGradientButton
title="Continuer avec Théo vers l'espace Playback"
onPress={handleContinueAdventure}
disabled={isDownloading || isCheckoutLaunching || isPublishing}
height={58}
titleStyle={styles.choiceButtonText}
/>
</View>
</ScrollView> </ScrollView>
) : (
<View style={styles.adventureGatePlaceholder} />
)}
<FullscreenIntroVideo url={showIntro} visible={!!showIntro} onClose={handleCloseIntro} /> <FullscreenIntroVideo url={showIntro} visible={!!showIntro} onClose={handleCloseIntro} />
<AdventureChoiceModal
isVisible={showAdventureModal}
setIsVisible={setShowAdventureModal}
onContinue={handleContinueAdventure}
onStop={handleStopAdventure}
/>
<ShareAndCreditsModal
isVisible={showShareModal}
setIsVisible={setShowShareModal}
onNavigateToCredits={() => {
setShowShareModal(false)
navigate(Routes.DownloadPrices, { action: 'song' })
}}
project={projectForStage}
selectedOption={selectedOption}
/>
</Page> </Page>
) )
} }
@@ -659,11 +632,15 @@ const styles = StyleSheet.create({
scrollContent: { scrollContent: {
paddingBottom: gutters * 2.6, paddingBottom: gutters * 2.6,
}, },
coverRow: { summaryCard: {
flexDirection: isWeb ? 'row' : 'column',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
gap: gutters * 0.8, gap: gutters * 0.8,
padding: gutters,
borderRadius: 20,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.14)',
backgroundColor: 'rgba(5, 12, 24, 0.76)',
}, },
coverImage: { coverImage: {
width: 150, width: 150,
@@ -680,24 +657,42 @@ const styles = StyleSheet.create({
fontFamily: FONT_FAMILY.InterMedium, fontFamily: FONT_FAMILY.InterMedium,
color: Palette.grayMid, color: Palette.grayMid,
}, },
downloadTile: { trackTitle: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
paddingVertical: gutters * 0.9,
paddingHorizontal: gutters * 1.2,
borderRadius: 14,
backgroundColor: '#8C4BFF',
borderWidth: 0,
},
downloadColumn: {
alignItems: 'center',
gap: 4,
},
downloadText: {
fontFamily: FONT_FAMILY.InterSemiBold, fontFamily: FONT_FAMILY.InterSemiBold,
fontSize: 15, fontSize: 18,
color: Palette.white, color: Palette.white,
textAlign: 'center',
},
choiceSection: {
gap: gutters * 0.65,
padding: gutters,
borderRadius: 20,
backgroundColor: 'rgba(37, 36, 56, 0.94)',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.12)',
},
continueSection: {
gap: gutters * 0.65,
padding: gutters,
borderRadius: 20,
backgroundColor: 'rgba(255, 255, 255, 0.07)',
},
sectionTitle: {
fontFamily: FONT_FAMILY.InterSemiBold,
fontSize: 18,
color: Palette.white,
},
sectionDescription: {
fontFamily: FONT_FAMILY.InterRegular,
fontSize: 14,
lineHeight: 20,
color: Palette.white,
opacity: 0.8,
marginBottom: 4,
},
choiceButtonText: {
textAlign: 'center',
fontSize: 14,
}, },
downloadPendingText: { downloadPendingText: {
marginTop: 6, marginTop: 6,
@@ -706,18 +701,6 @@ const styles = StyleSheet.create({
color: Palette.white, color: Palette.white,
opacity: 0.8, opacity: 0.8,
}, },
adventureGatePlaceholder: {
flex: 1,
},
clubCardSpacing: {
marginTop: gutters * 0.5,
},
returnHomeButton: {
width: '100%',
maxWidth: 320,
alignSelf: 'center',
marginTop: gutters * 0.6,
},
}) })
export default SongDownload export default SongDownload
@@ -1,64 +0,0 @@
import React from 'react'
import { StyleSheet, Text, View } from 'react-native'
import { gutters, Palette } from '../../../styles'
import { FONT_FAMILY } from '../../../styles/Fonts'
import BorderGradientButton from '../../../components/BorderGradientButton'
import GradientButton from '../../../components/GradientButton'
import Overlay from '../../../components/Overlay'
const AdventureChoiceModal = ({ isVisible, setIsVisible, onContinue, onStop }) => {
return (
<Overlay isVisible={isVisible} setIsVisible={setIsVisible} blurIntensity={15}>
<View style={styles.modalCard}>
<Text style={styles.modalTitle}>Quelle est la suite ?</Text>
<View style={styles.modalActions}>
<GradientButton
title="Je continue l'aventure Musicland vers l'espace vidéo"
onPress={onContinue}
containerStyle={styles.modalAction}
textStyle={styles.modalActionText}
/>
<BorderGradientButton
title="J'arrête l'aventure et je télécharge ma chanson"
onPress={onStop}
containerStyle={styles.modalAction}
titleStyle={styles.modalActionText}
/>
</View>
</View>
</Overlay>
)
}
export default AdventureChoiceModal
const styles = StyleSheet.create({
modalCard: {
width: '90%',
maxWidth: 420,
alignSelf: 'center',
padding: gutters * 1.4,
borderRadius: 20,
backgroundColor: 'rgba(37, 36, 56, 0.96)',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.12)',
gap: gutters * 0.8,
},
modalTitle: {
fontFamily: FONT_FAMILY.InterSemiBold,
fontSize: 18,
color: Palette.white,
textAlign: 'center',
marginBottom: 8,
},
modalActions: {
gap: gutters * 0.6,
marginTop: gutters * 0.6,
},
modalAction: {
width: '100%',
},
modalActionText: {
textAlign: 'center',
},
})
@@ -1,826 +0,0 @@
import React, { useMemo, useState, useCallback } from 'react'
import {
StyleSheet,
Text,
View,
Pressable,
Linking,
Alert,
ScrollView,
ActivityIndicator,
} from 'react-native'
import { MaterialCommunityIcons } from '@expo/vector-icons'
import * as Sharing from 'expo-sharing'
import { BlurView } from 'expo-blur'
import { Image as ExpoImage } from 'expo-image'
import { gutters, Palette } from '../../../styles'
import { FONT_FAMILY } from '../../../styles/Fonts'
import Overlay from '../../../components/Overlay'
import { useStripe } from '../../../providers/StripeProvider'
import CreditAmount from '../../../components/CreditAmount'
import GradientButton from '../../../components/GradientButton'
import AppCheckbox from '../../../components/AppCheckbox'
import { icons, planBadges } from '../../../assets'
import { isWeb } from '../../../hooks/useLayoutType'
import { navigate } from '../../../navigation/NavigationService'
import { Routes } from '../../../navigation'
import { musiclandShareHeading } from '../../../data'
import {
COIN_PACK_DETAILS,
COIN_PACK_SECTION_TITLE,
getCoinPackKey,
} from '../../../utils/coinPackDisplay'
import {
getSubscriptionCardDisplay,
getSubscriptionPlanKey,
} from '../../../utils/subscriptionCardDisplay'
// --- HELPER FUNCTIONS & CONSTANTS (Copied from Subscriptions.js) ---
const formatCurrency = (amount, currency = 'eur') => {
if (typeof amount !== 'number') {
return null
}
const normalized = amount / 100
const upperCurrency =
typeof currency === 'string' && currency.trim() ? currency.trim().toUpperCase() : 'EUR'
if (typeof Intl !== 'undefined' && Intl.NumberFormat) {
try {
return new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: upperCurrency,
minimumFractionDigits: 2,
}).format(normalized)
} catch (_error) {
// Ignore
}
}
return `${normalized.toFixed(2)} ${upperCurrency}`
}
const getIntervalLabel = (recurring) => {
if (!recurring || typeof recurring !== 'object') {
return null
}
const interval = recurring.interval || 'month'
const count = recurring.interval_count || 1
const vocabulary = {
day: { singular: 'jour', plural: 'jours' },
week: { singular: 'semaine', plural: 'semaines' },
month: { singular: 'mois', plural: 'mois' },
year: { singular: 'an', plural: 'ans' },
}
const terms = vocabulary[interval] || vocabulary.month
if (count <= 1) {
return `par ${terms.singular}`
}
return `tous les ${count} ${count > 1 ? terms.plural : terms.singular}`
}
// --- SUBSCRIPTION CARD COMPONENT (Copied) ---
function SubscriptionCard({ plan, selected, onSelect, isAnnual }) {
const planKey = getSubscriptionPlanKey(plan)
const formattedPrice = formatCurrency(plan?.unitAmount, plan?.currency)
const intervalLabel = getIntervalLabel(plan?.recurring)
const coinsPerMonth =
typeof plan?.coinsPerMonth === 'number' && Number.isFinite(plan.coinsPerMonth)
? Math.round(plan.coinsPerMonth)
: null
const cardDisplay = getSubscriptionCardDisplay({
planKey,
isAnnual,
formattedPrice,
intervalLabel,
coinsPerMonth,
fallbackTitle: plan?.product?.name || plan?.nickname || plan?.priceId || 'Abonnement',
})
const isPopular = cardDisplay.popular
const planBadgeKey = planKey
const planBadgeSource = planBadgeKey && planBadges[planBadgeKey] ? planBadges[planBadgeKey] : null
const handleSelect = React.useCallback(() => {
if (typeof onSelect === 'function' && plan?.priceId) {
onSelect(plan.priceId)
}
}, [onSelect, plan?.priceId])
return (
<Pressable
onPress={handleSelect}
style={({ pressed }) => [
styles.cardWrapper,
selected && styles.cardWrapperSelected,
pressed && styles.cardWrapperPressed,
plan?.active === false && styles.cardWrapperInactive,
isPopular && styles.cardWrapperPopular,
]}
disabled={plan?.active === false}
>
<BlurView intensity={18} tint="dark" style={styles.cardBlur}>
<View style={styles.cardContent}>
{plan?.badge ? (
<View style={styles.badge}>
<Text style={styles.badgeText}>{plan.badge}</Text>
</View>
) : null}
<View style={styles.cardHeader}>
<View style={styles.titleRow}>
<Text style={styles.planName}>{cardDisplay.title}</Text>
{planBadgeSource ? (
<ExpoImage
source={planBadgeSource}
style={styles.planBadgeImage}
contentFit="contain"
/>
) : null}
</View>
</View>
{cardDisplay.primaryPrice || cardDisplay.creditsLabel ? (
<View style={styles.priceBlock}>
{cardDisplay.primaryPrice ? (
<Text style={styles.priceValue}>{cardDisplay.primaryPrice}</Text>
) : null}
{cardDisplay.secondaryPrice ? (
<Text style={styles.secondaryPriceText}>{cardDisplay.secondaryPrice}</Text>
) : null}
{cardDisplay.creditsLabel ? (
<Text style={styles.coinsPromoText}>{cardDisplay.creditsLabel}</Text>
) : null}
</View>
) : null}
{Array.isArray(cardDisplay.benefitItems) && cardDisplay.benefitItems.length ? (
<View style={styles.cardBenefits}>
{cardDisplay.benefitItems.map((item, index) =>
item?.type === 'plus' ? (
<Text key={`benefit-plus-${index}`} style={styles.benefitPlus}>
{item.text}
</Text>
) : (
<Text key={`benefit-text-${index}`} style={styles.downloadLabel}>
{item.text}
</Text>
)
)}
</View>
) : null}
{isPopular ? (
<View style={styles.popularBadge}>
<Text style={styles.popularBadgeText}>Le plus populaire !</Text>
</View>
) : null}
</View>
</BlurView>
</Pressable>
)
}
// --- MAIN WRAPPER COMPONENT ---
const ShareAndCreditsModal = ({
isVisible,
setIsVisible,
project,
selectedOption,
}) => {
const trackTitle = project?.title || 'Musicland Track'
const {
subscriptions,
coinPacks,
createSubscriptionCheckout,
createCoinPackCheckout,
isCatalogLoading,
} = useStripe()
const [selectedPeriodKey, setSelectedPeriodKey] = useState('monthly')
const [selectedPriceId, setSelectedPriceId] = useState(null)
const [processingPriceId, setProcessingPriceId] = useState(null)
const [errorMessage, setErrorMessage] = useState(null)
const [hasAcceptedPublication, setHasAcceptedPublication] = useState(true)
const normalizedPlansByPeriod = useMemo(() => {
// Basic normalization/filter logic similar to Subscriptions.js
const normalize = (plans) => {
if (!Array.isArray(plans)) return []
return plans.filter((p) => p && p.priceId)
}
return {
monthly: normalize(subscriptions?.monthly),
annual: normalize(subscriptions?.annual),
}
}, [subscriptions])
// Set default selection
React.useEffect(() => {
const plans = normalizedPlansByPeriod[selectedPeriodKey] || []
if (plans.length > 0 && !selectedPriceId) {
// Optional: Auto-select popular/first plan
// But for now let's leave it null to force user choice or pick first
}
}, [normalizedPlansByPeriod, selectedPeriodKey, selectedPriceId])
const handleShare = async (platform) => {
const shareUrl =
project?.songUrl ||
project?.playbackUrl ||
selectedOption?.finalUrl ||
'https://musicland.ai'
const shareText = `Check out my new song "${trackTitle}" created with MusicLand! ${shareUrl}`
if (platform === 'whatsapp') {
Linking.openURL(`whatsapp://send?text=${encodeURIComponent(shareText)}`).catch(() => {
Alert.alert('Erreur', 'WhatsApp is not installed')
})
} else if (platform === 'facebook') {
Linking.openURL(
`https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareUrl)}`
).catch(() => {
Alert.alert('Erreur', 'Unable to open Facebook')
})
} else if (platform === 'instagram' || platform === 'tiktok') {
if (await Sharing.isAvailableAsync()) {
await Sharing.shareAsync(shareUrl, { dialogTitle: musiclandShareHeading })
} else {
Linking.openURL(shareUrl)
}
}
}
const handleBuyCredits = async (pack) => {
const targetId = pack?.productId
if (!targetId) return
setProcessingPriceId(targetId)
setErrorMessage(null)
try {
await createCoinPackCheckout(targetId)
} catch (error) {
setErrorMessage(error?.message || 'Erreur lors de l\'achat de crédits.')
} finally {
setProcessingPriceId(null)
}
}
const handleSubscribe = async () => {
if (!selectedPriceId) return
setProcessingPriceId(selectedPriceId)
try {
await createSubscriptionCheckout(selectedPriceId)
} catch (error) {
setErrorMessage(error?.message || 'Erreur lors de l\'abonnement.')
} finally {
setProcessingPriceId(null)
}
}
const handleGoHome = useCallback(() => {
setIsVisible(false)
navigate(Routes.Home)
}, [setIsVisible])
const currentPlans = normalizedPlansByPeriod[selectedPeriodKey] || []
return (
<Overlay isVisible={isVisible} setIsVisible={setIsVisible} blurIntensity={20}>
<View style={styles.modalCard}>
<View style={styles.closeButtonContainer}>
<Pressable onPress={() => setIsVisible(false)} style={styles.closeButton}>
<MaterialCommunityIcons name="close" size={24} color={Palette.white} />
</Pressable>
</View>
<ScrollView contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
{/* SHARE SECTION */}
<Text style={styles.modalTitle}>Partage ton succès !</Text>
<View style={styles.shareSection}>
<Text style={styles.shareSubtitle}>Partage mon morceau</Text>
<View style={styles.shareButtons}>
<Pressable style={styles.shareBtn} onPress={() => handleShare('whatsapp')}>
<MaterialCommunityIcons name="whatsapp" size={32} color="#25D366" />
</Pressable>
<Pressable style={styles.shareBtn} onPress={() => handleShare('facebook')}>
<MaterialCommunityIcons name="facebook" size={32} color="#1877F2" />
</Pressable>
<Pressable style={styles.shareBtn} onPress={() => handleShare('instagram')}>
<MaterialCommunityIcons name="instagram" size={32} color="#C13584" />
</Pressable>
<Pressable style={styles.shareBtn} onPress={() => handleShare('tiktok')}>
<MaterialCommunityIcons name="music-note" size={32} color="#000000" />
</Pressable>
</View>
</View>
{/* CREDITS SECTION */}
{coinPacks && coinPacks.length > 0 ? (
<View style={styles.sectionContainer}>
<Text style={styles.sectionTitle}>{COIN_PACK_SECTION_TITLE}</Text>
<View style={styles.creditsGrid}>
{coinPacks.map((pack) => {
const rawAmount = pack.unitAmount ?? pack.amount ?? pack.price ?? 0
const price = formatCurrency(rawAmount, pack.currency)
const coins = Number(pack.metadata?.coins || 0)
const packKey = getCoinPackKey(pack)
const details = packKey ? COIN_PACK_DETAILS[packKey] : null
const displayName = details?.label || pack.name
const creditsText = details?.creditsDisplay
const displayPrice = details?.priceDisplay || price || `${rawAmount / 100}`
const isPopular = details?.popular === true
return (
<View key={pack.productId} style={styles.creditCard}>
<View style={styles.creditInfoContainer}>
<Text style={styles.creditName}>{displayName}</Text>
{creditsText ? (
<Text style={styles.creditAmountCustom}>{creditsText}</Text>
) : (
<CreditAmount
value={coins}
iconSize={24}
textStyle={styles.creditAmountText}
/>
)}
{details?.disclaimer ? (
<Text style={styles.packDisclaimer}>{details.disclaimer}</Text>
) : null}
</View>
{isPopular ? (
<View style={styles.popularBadge}>
<Text style={styles.popularBadgeText}>Le plus populaire !</Text>
</View>
) : null}
<GradientButton
title={processingPriceId === pack.productId ? '...' : displayPrice}
onPress={() => handleBuyCredits(pack)}
disabled={Boolean(processingPriceId)}
style={styles.creditButton}
textStyle={styles.creditButtonText}
gradientStyle={{ paddingVertical: 8, paddingHorizontal: 16 }}
/>
</View>
)
})}
</View>
</View>
) : null}
{/* SUBSCRIPTIONS SECTION */}
<View style={styles.sectionContainer}>
<Text style={styles.sectionTitle}>Rejoignez le club MusicLand</Text>
{/* Period Toggle */}
<View style={styles.segmentedControl}>
{['monthly', 'annual'].map((key) => {
const isActive = selectedPeriodKey === key
const label = key === 'monthly' ? 'Mensuel' : 'Annuel'
const showAnnualPromo = key === 'annual'
return (
<Pressable
key={key}
onPress={() => {
setSelectedPeriodKey(key)
setSelectedPriceId(null)
}}
style={[styles.segmentButton, isActive && styles.segmentButtonActive]}
>
<Text style={[styles.segmentLabel, isActive && styles.segmentLabelActive]}>
{label}
</Text>
{showAnnualPromo && (
<View style={styles.segmentBadge}>
<Text style={styles.segmentBadgeText}>-16%</Text>
</View>
)}
</Pressable>
)
})}
</View>
{/* Plans Grid */}
<View style={styles.plansGrid}>
{isCatalogLoading && !currentPlans.length ? (
<ActivityIndicator color={Palette.white} />
) : (
currentPlans.map((plan) => (
<View key={plan.priceId} style={{ width: isWeb ? '30%' : '100%' }}>
<SubscriptionCard
plan={plan}
selected={selectedPriceId === plan.priceId}
onSelect={setSelectedPriceId}
isAnnual={selectedPeriodKey === 'annual'}
/>
</View>
))
)}
</View>
<View style={{ marginTop: 20, width: '100%', alignItems: 'center' }}>
<GradientButton
title={processingPriceId && !coinPacks.find(p => p.productId === processingPriceId) ? 'Traitement...' : 'Choisir cet abonnement'}
onPress={handleSubscribe}
disabled={!selectedPriceId || Boolean(processingPriceId)}
style={{ width: isWeb ? 320 : '100%' }}
/>
</View>
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
</View>
{/* BROADCAST SECTION */}
<View style={styles.broadcastSection}>
<View style={styles.consentContainer}>
<AppCheckbox
selected={hasAcceptedPublication}
onPress={() => setHasAcceptedPublication((prev) => !prev)}
label="J'accepte de diffuser mon contenu sur la plateforme de streaming de musicland et sur youtube"
/>
</View>
<GradientButton
title="Diffuser"
onPress={handleGoHome}
disabled={!hasAcceptedPublication}
containerStyle={styles.broadcastButton}
/>
</View>
</ScrollView>
</View>
</Overlay>
)
}
export default ShareAndCreditsModal
const styles = StyleSheet.create({
modalCard: {
width: isWeb ? '90%' : '95%',
maxWidth: 1000,
height: isWeb ? '90%' : '85%',
alignSelf: 'center',
borderRadius: 24,
backgroundColor: 'rgba(23, 22, 33, 0.98)',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.12)',
overflow: 'hidden',
padding: 0,
},
scrollContent: {
padding: gutters,
gap: gutters * 1.5,
paddingBottom: gutters * 2,
},
closeButtonContainer: {
position: 'absolute',
top: 12,
right: 12,
zIndex: 100,
},
closeButton: {
padding: 8,
backgroundColor: 'rgba(255,255,255,0.1)',
borderRadius: 20,
},
modalTitle: {
fontFamily: FONT_FAMILY.InterBold,
fontSize: 24,
color: Palette.white,
textAlign: 'center',
marginTop: 10,
},
shareSection: {
gap: 12,
alignItems: 'center',
},
shareSubtitle: {
fontSize: 16,
fontFamily: FONT_FAMILY.InterSemiBold,
color: Palette.white,
opacity: 0.9,
},
shareButtons: {
flexDirection: 'row',
gap: 20,
justifyContent: 'center',
},
shareBtn: {
padding: 8,
borderRadius: 50,
backgroundColor: 'rgba(255, 255, 255, 0.1)',
},
sectionContainer: {
gap: 16,
width: '100%',
alignItems: 'center',
borderTopWidth: 1,
borderTopColor: 'rgba(255, 255, 255, 0.08)',
paddingTop: gutters,
},
sectionTitle: {
fontFamily: FONT_FAMILY.InterBold,
fontSize: 20,
color: Palette.white,
textAlign: 'center',
},
creditsGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 16,
justifyContent: 'center',
width: '100%',
},
creditCard: {
backgroundColor: 'rgba(255, 255, 255, 0.05)',
borderRadius: 16,
padding: 16,
alignItems: 'center',
justifyContent: 'flex-start',
width: isWeb ? 220 : '100%',
minHeight: 160,
gap: 12,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
},
creditInfoContainer: {
alignItems: 'center',
gap: 4,
},
creditName: {
fontFamily: FONT_FAMILY.InterSemiBold,
fontSize: 14,
color: Palette.white,
textAlign: 'center',
marginBottom: 4,
},
creditAmountCustom: {
fontFamily: FONT_FAMILY.InterBold,
fontSize: 16,
color: Palette.white,
textAlign: 'center',
},
creditAmountText: {
fontSize: 20,
},
packDisclaimer: {
fontFamily: FONT_FAMILY.InterRegular,
fontSize: 10,
color: 'rgba(255, 255, 255, 0.6)',
fontStyle: 'italic',
textAlign: 'center',
marginTop: 4,
},
creditButton: {
marginTop: 'auto',
width: '100%',
height: 36,
minHeight: 36,
},
creditButtonText: {
fontSize: 13,
},
segmentedControl: {
flexDirection: 'row',
alignSelf: 'center',
justifyContent: 'center',
padding: 4,
borderRadius: 999,
backgroundColor: 'rgba(255, 255, 255, 0.08)',
},
segmentButton: {
paddingVertical: 8,
paddingHorizontal: 18,
borderRadius: 999,
position: 'relative',
},
segmentButtonActive: {
backgroundColor: 'rgba(255, 255, 255, 0.18)',
},
segmentLabel: {
fontFamily: FONT_FAMILY.InterMedium,
fontSize: 14,
color: 'rgba(255, 255, 255, 0.7)',
},
segmentLabelActive: {
color: Palette.white,
},
segmentBadge: {
position: 'absolute',
top: -6,
right: -8,
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 999,
backgroundColor: Palette.red,
},
segmentBadgeText: {
fontFamily: FONT_FAMILY.InterSemiBold,
fontSize: 11,
color: Palette.white,
},
plansGrid: {
width: '100%',
flexDirection: isWeb ? 'row' : 'column',
flexWrap: 'wrap',
justifyContent: 'center',
gap: 16,
},
// Subscription Card Styles
cardWrapper: {
width: '100%',
minHeight: 280,
borderRadius: 24,
overflow: 'hidden',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.12)',
backgroundColor: 'rgba(12, 14, 18, 0.45)',
},
cardWrapperSelected: {
borderColor: Palette.primary,
shadowColor: '#000',
shadowOffset: { width: 0, height: 12 },
shadowOpacity: 0.35,
shadowRadius: 20,
elevation: 8,
},
cardWrapperPopular: {
borderColor: Palette.primary,
backgroundColor: 'rgba(112, 35, 247, 0.1)',
},
cardWrapperPressed: {
transform: [{ scale: 0.98 }],
},
cardWrapperInactive: {
opacity: 0.6,
},
cardBlur: {
flex: 1,
padding: 16,
gap: 16,
},
cardContent: {
flex: 1,
gap: 16,
},
badge: {
alignSelf: 'flex-start',
backgroundColor: 'rgba(112, 35, 247, 0.25)',
borderRadius: 12,
paddingHorizontal: 8,
paddingVertical: 4,
marginBottom: 8,
},
badgeText: {
color: Palette.primary,
fontSize: 11,
fontFamily: FONT_FAMILY.InterSemiBold,
},
cardHeader: {
gap: 4,
},
titleRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
planName: {
color: Palette.white,
fontSize: 18,
fontFamily: FONT_FAMILY.InterBold,
},
planBadgeImage: {
width: 40,
height: 40,
},
cardSubtitle: {
color: 'rgba(255,255,255,0.7)',
fontSize: 13,
},
priceBlock: {
marginBottom: 8,
},
secondaryPriceText: {
color: Palette.primary,
fontSize: 15,
fontFamily: FONT_FAMILY.InterBold,
},
priceRow: {
flexDirection: 'row',
alignItems: 'baseline',
gap: 6,
},
priceValue: {
color: Palette.white,
fontSize: 20,
fontFamily: FONT_FAMILY.InterBold,
},
period: {
color: 'rgba(255,255,255,0.6)',
fontSize: 13,
},
coinsPromoText: {
color: Palette.primary,
fontSize: 14,
fontFamily: FONT_FAMILY.InterBold,
},
coinsPerMonthRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
coinsPerMonth: {
fontSize: 14,
color: Palette.primary,
},
coinsPerMonthSuffix: {
fontSize: 13,
color: Palette.primary,
},
cardBenefits: {
gap: 6,
},
downloadLabel: {
color: Palette.white,
fontSize: 13,
fontFamily: FONT_FAMILY.InterMedium,
textAlign: 'center',
},
benefitPlus: {
color: Palette.white,
fontSize: 22,
fontFamily: FONT_FAMILY.InterBold,
textAlign: 'center',
lineHeight: 24,
},
benefitsTitle: {
color: Palette.white,
fontSize: 13,
fontFamily: FONT_FAMILY.InterSemiBold,
},
benefitBlock: {
flexDirection: 'row',
},
benefitText: {
color: 'rgba(255,255,255,0.8)',
fontSize: 12,
},
benefitTextBold: {
fontFamily: FONT_FAMILY.InterSemiBold,
color: Palette.white,
},
cardBenefitsItem: {
color: 'rgba(255,255,255,0.8)',
fontSize: 12,
},
popularBadge: {
alignSelf: 'center',
backgroundColor: Palette.primary,
paddingHorizontal: 12,
paddingVertical: 4,
borderRadius: 12,
marginTop: 'auto',
},
popularBadgeText: {
color: Palette.white,
fontSize: 10,
fontFamily: FONT_FAMILY.InterBold,
textTransform: 'uppercase',
},
annualBadge: {
position: 'absolute',
top: 10,
right: 10,
backgroundColor: Palette.red + '80',
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 12,
},
annualBadgeText: {
color: Palette.white + '80',
fontSize: 11,
fontFamily: FONT_FAMILY.InterBold,
},
errorText: {
color: Palette.red,
textAlign: 'center',
marginTop: 10,
},
broadcastSection: {
marginTop: gutters,
paddingTop: gutters,
borderTopWidth: 1,
borderTopColor: 'rgba(255, 255, 255, 0.08)',
gap: gutters,
width: '100%',
alignItems: 'center',
},
consentContainer: {
width: '100%',
paddingHorizontal: gutters,
},
broadcastButton: {
width: isWeb ? 320 : '100%',
},
})
+12 -49
View File
@@ -1,6 +1,6 @@
import { Routes } from '../navigation/Routes' import { Routes } from '../navigation/Routes'
export const CREATION_STAGE_KEYS = ['songwriter', 'beatmaker', 'director', 'publisher'] export const CREATION_STAGE_KEYS = ['songwriter', 'beatmaker', 'director']
const getSectionLyrics = (section) => { const getSectionLyrics = (section) => {
if (typeof section === 'string') { if (typeof section === 'string') {
@@ -36,13 +36,6 @@ const getStageMetadata = (project) => {
const hasCoverUrl = !!project?.coverUrl const hasCoverUrl = !!project?.coverUrl
const hasPlaybackAsset = !!project?.playbackUrl const hasPlaybackAsset = !!project?.playbackUrl
const hasPlayback = hasPlaybackAsset || hasSongUrl const hasPlayback = hasPlaybackAsset || hasSongUrl
const youtubeUrl = project?.youtubeUrl ?? null
const youtubeStatus = project?.youtubeStatus ?? null
const youtubeError = project?.youtubeError ?? null
const hasYoutubePublication = !!youtubeUrl
const isYoutubePublishing = ['PUBLISHING', 'UPLOADING', 'PROCESSING', 'QUEUED'].includes(
youtubeStatus
)
const musicUrls = Array.isArray(project?.musicUrls) ? project.musicUrls.filter(Boolean) : [] const musicUrls = Array.isArray(project?.musicUrls) ? project.musicUrls.filter(Boolean) : []
const musicStatus = project?.musicStatus || null const musicStatus = project?.musicStatus || null
const coverStatus = project?.coverStatus || null const coverStatus = project?.coverStatus || null
@@ -65,13 +58,9 @@ const getStageMetadata = (project) => {
coverStatus, coverStatus,
playbackStatus, playbackStatus,
isPlaybackDraftReady, isPlaybackDraftReady,
youtubeStatus,
youtubeError,
isMusicGenerating: musicStatus === 'GENERATING', isMusicGenerating: musicStatus === 'GENERATING',
isCoverGenerating: coverStatus === 'GENERATING', isCoverGenerating: coverStatus === 'GENERATING',
isPlaybackGenerating, isPlaybackGenerating,
hasYoutubePublication,
isYoutubePublishing,
} }
} }
@@ -85,12 +74,7 @@ const getStageLockState = (key, metadata) => {
} }
return metadata.lyricsCount <= 0 return metadata.lyricsCount <= 0
case 'director': case 'director':
if (metadata.hasPlaybackAsset) {
return true
}
return !metadata.hasCover return !metadata.hasCover
case 'publisher':
return !metadata.hasPlaybackAsset
default: default:
return true return true
} }
@@ -148,23 +132,9 @@ const getStageDescription = (key, metadata) => {
return 'Votre playback IA est prêt à être validé' return 'Votre playback IA est prêt à être validé'
} }
if (hasPlaybackAsset) { if (hasPlaybackAsset) {
return 'Modifier le playback généré' return 'Télécharger ou publier votre playback'
} }
return 'Commencer la création de votre playback' return 'Commencer la création de votre playback'
case 'publisher':
if (!hasPlaybackAsset) {
return 'Créez un playback pour débloquer la publication'
}
if (metadata.isYoutubePublishing) {
return 'Publication de votre vidéo en cours'
}
if (metadata.youtubeError) {
return 'La publication a échoué, réessayez.'
}
if (metadata.hasYoutubePublication) {
return 'Votre vidéo est en ligne et prête à être partagée'
}
return 'Publier votre vidéo sur la chaîne YouTube MusicLand'
default: default:
return '' return ''
} }
@@ -180,12 +150,7 @@ const getStageLockedDescription = (key, metadata) => {
} }
return metadata.lyricsCount > 0 ? undefined : 'Créez vos paroles pour débloquer le studio' return metadata.lyricsCount > 0 ? undefined : 'Créez vos paroles pour débloquer le studio'
case 'director': case 'director':
return metadata.hasPlaybackAsset ? 'Impossible de modifier le playback' : undefined return undefined
case 'publisher':
if (!metadata.hasPlaybackAsset) {
return 'Générez un playback pour débloquer la publication'
}
return metadata.hasYoutubePublication ? 'La vidéo est déjà publiée' : undefined
default: default:
return undefined return undefined
} }
@@ -199,8 +164,6 @@ const getStageCompletionState = (key, metadata) => {
return metadata.hasSongUrl || metadata.musicStatus === 'GENERATED' return metadata.hasSongUrl || metadata.musicStatus === 'GENERATED'
case 'director': case 'director':
return metadata.hasPlaybackAsset return metadata.hasPlaybackAsset
case 'publisher':
return metadata.hasYoutubePublication
default: default:
return false return false
} }
@@ -264,19 +227,19 @@ export const getStageAction = (key, project) => {
return { route: hasFinalCover ? Routes.ChooseCoverType : Routes.PouchReady } return { route: hasFinalCover ? Routes.ChooseCoverType : Routes.PouchReady }
} }
case 'director': case 'director':
if (project?.playbackUrl) {
return {
route: Routes.PlaybackDownload,
params: {
action: 'playback',
project,
},
}
}
return { return {
route: Routes.Playback, route: Routes.Playback,
params: project ? { project } : undefined, params: project ? { project } : undefined,
} }
case 'publisher': {
if (!project?.id) {
return { route: Routes.PublishYoutube }
}
return {
route: Routes.PublishYoutube,
params: { projectId: project.id },
}
}
default: default:
return {} return {}
} }
+33
View File
@@ -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
}
-77
View File
@@ -1,77 +0,0 @@
# Publication YouTube guide refresh token
## 1. Récupérer un code d'autorisation
### 1.1 Trouver ou créer le Client ID
1. Ouvre la console Google Cloud : <https://console.cloud.google.com/apis/credentials>
2. Sélectionne le projet lié au compte YouTube.
3. Dans longlet « Identifiants », repère lID client OAuth 2.0 existant au format :
```
123456789012-abcdefghijklmnopqrstu.apps.googleusercontent.com
```
Copie-le : cest `YOUR_CLIENT_ID`.
4. Sil nexiste pas encore, clique sur « Créer des identifiants » → « ID client OAuth » → type « Application Web » et ajoute `http://localhost:8081` dans les URIs de redirection autorisées. Enregistre, puis note lID client et le secret.
### 1.2 Générer un code d'autorisation
1. Ouvre cette URL dans un navigateur (mets à jour `redirect_uri` si besoin) :
```
https://accounts.google.com/o/oauth2/v2/auth?client_id=305598753437-3mn43cs1phacao2f1ctg1dbur7rct0bt.apps.googleusercontent.com&redirect_uri=http://localhost:8081&response_type=code&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fyoutube.upload&access_type=offline&prompt=consent
```
2. Connecte-toi au compte YouTube cible et accepte les permissions.
3. Google te redirige vers `http://localhost:8081/?code=...`. Copie la valeur du paramètre `code`.
## 2. Échanger le code contre un refresh token
```bash
curl -X POST https://oauth2.googleapis.com/token \
-d client_id=YOUR_CLIENT_ID \
-d client_secret=YOUR_CLIENT_SECRET \
-d code="CODE_RECU" \
-d grant_type=authorization_code \
-d redirect_uri=http://localhost:8081
```
La réponse JSON contient :
- `access_token` (jeton court terme),
- `refresh_token` (à conserver),
- `expires_in`, `scope`, `token_type`.
⚠️ Le `refresh_token` nest renvoyé quune seule fois avec `access_type=offline`. Garde-le de manière sécurisée.
## 3. Mettre à jour les variables d'environnement Firebase
1. Enregistre les secrets côté Firebase (adapter le projet via `-P` si besoin) :
```bash
firebase functions:config:set \
YOUTUBE_CLIENT_ID="YOUR_CLIENT_ID.apps.googleusercontent.com" \
YOUTUBE_CLIENT_SECRET="YOUR_CLIENT_SECRET" \
YOUTUBE_REFRESH_TOKEN="YOUR_REFRESH_TOKEN"
YOUTUBE_REDIRECT_URI="http://localhost:8081" \
YOUTUBE_PRIVACY_STATUS="public" \
YOUTUBE_CATEGORY_ID="10" # 10 = Catégorie youtube de musique
```
2. Vérifie ce qui est stocké :
```bash
firebase functions:config:get
```
3. Redéploie la fonction :
```bash
firebase deploy --only functions:youtube
```
## 4. Exemple de réponse OAuth
```
http://localhost:8081/?code=4/0Ab32j90LMDeJDShWSk1OkXwgbjrHS-qQpyYTlXhH9q3nlV0EJa6cTL-7nI9yOKHoz6rgRA&scope=https://www.googleapis.com/auth/youtube.upload
{
"access_token": "ya29.a0ATi6K2uR4DhnmTDl0EN2tvDogwwEwl8SDJLpUzvnZ8LGgOYjgmhqg7MoMM0_iTJR_NBEDPe2LlJDqCFFnIi9s3qRv_zfw_NeTnN-LwUmAyVJkdqSP6GvzQCuZkE6OBuTvaU5b2obk5rHvDOkWXxAb7xjbVTzIBIg8O5VVhJ-VB8VEbTVsSq9bc0ljkbcyBEzm4JdQRwaCgYKAcgSAQ8SFQHGX2MiR1Owf2aiiEBUsU-LDUDgWg0206",
"expires_in": 3599,
"refresh_token": "1//03sy-LDVmCMcFCgYIARAAGAMSNwF-L9IrHwwdBBZvg3Hwun9hxHUx_AsJaa1qeozmtvGtdOXKIRIyKjTp7S_b0igvzM_1A5bLH34",
"scope": "https://www.googleapis.com/auth/youtube.upload",
"token_type": "Bearer"
}
```