Compare commits
10 Commits
7dbe60f500
...
3ba290031d
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ba290031d | |||
| efbb7994f2 | |||
| 7bf913e014 | |||
| 7521f5939f | |||
| 99431f9ae4 | |||
| f4549b694f | |||
| 4e14a38d4e | |||
| fe51439bbe | |||
| bc7edcf3a7 | |||
| 4fb8a3e1e0 |
@@ -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.
@@ -0,0 +1,82 @@
|
|||||||
|
# Chiffrage D-ID API (image + musique) - MusicLand
|
||||||
|
|
||||||
|
Date de référence: 11 février 2026
|
||||||
|
Périmètre: API uniquement (pas Studio en usage manuel), génération vidéo à partir d'une image + audio musique.
|
||||||
|
|
||||||
|
## 1. Hypothèses retenues
|
||||||
|
|
||||||
|
- Durée musique utilisateur: 2:30 à 3:20.
|
||||||
|
- Durée moyenne de calcul: 2:55.
|
||||||
|
- Facturation D-ID (source pricing officielle): une vidéo est arrondie à la minute supérieure pour estimer la consommation.
|
||||||
|
- Barème public visible (abonnement annuel):
|
||||||
|
1. Lite: 4.7 USD/mois, 10 min/mois.
|
||||||
|
2. Pro: 16 USD/mois, 15 min/mois.
|
||||||
|
3. Advanced: 108 USD/mois, 100 min/mois.
|
||||||
|
|
||||||
|
Note usage commercial:
|
||||||
|
- D-ID indique que les plans Pro/Advanced/Enterprise/Launch/Scale incluent l'usage commercial et la monétisation.
|
||||||
|
- Trial/Lite/Build sont indiqués comme usage personnel.
|
||||||
|
|
||||||
|
## 2. Conversion durée -> minutes facturées
|
||||||
|
|
||||||
|
1. 2:30 (150s) -> 3 min facturées.
|
||||||
|
2. 2:55 (175s) -> 3 min facturées.
|
||||||
|
3. 3:20 (200s) -> 4 min facturées.
|
||||||
|
|
||||||
|
## 3. Coût par vidéo (API)
|
||||||
|
|
||||||
|
Formule:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Coût vidéo = (Prix mensuel du plan / minutes incluses mensuelles) x minutes facturées
|
||||||
|
```
|
||||||
|
|
||||||
|
Coût minute estimé:
|
||||||
|
|
||||||
|
1. Lite: 0.47 USD/min
|
||||||
|
2. Pro: 1.07 USD/min
|
||||||
|
3. Advanced: 1.08 USD/min
|
||||||
|
|
||||||
|
Coût vidéo selon durée:
|
||||||
|
|
||||||
|
| Plan | 2:30 (3 min) | 2:55 (3 min) | 3:20 (4 min) |
|
||||||
|
|---|---:|---:|---:|
|
||||||
|
| Lite | 1.41 USD | 1.41 USD | 1.88 USD |
|
||||||
|
| Pro | 3.20 USD | 3.20 USD | 4.27 USD |
|
||||||
|
| Advanced | 3.24 USD | 3.24 USD | 4.32 USD |
|
||||||
|
|
||||||
|
## 4. Budget mensuel selon volume (moyenne 2:55)
|
||||||
|
|
||||||
|
Hypothèse moyenne: 3 min facturées par vidéo.
|
||||||
|
|
||||||
|
| Volume | Minutes facturées | Budget Lite | Budget Pro | Budget Advanced |
|
||||||
|
|---|---:|---:|---:|---:|
|
||||||
|
| 100 vidéos/mois | 300 min | 141 USD | 320 USD | 324 USD |
|
||||||
|
| 500 vidéos/mois | 1500 min | 705 USD | 1600 USD | 1620 USD |
|
||||||
|
| 1000 vidéos/mois | 3000 min | 1410 USD | 3200 USD | 3240 USD |
|
||||||
|
|
||||||
|
## 5. Lecture business rapide
|
||||||
|
|
||||||
|
1. Pour une app commerciale, il faut viser au minimum un plan avec droits commerciaux (Pro/Advanced/Launch/Scale selon ton contrat exact).
|
||||||
|
2. À durée 2:30-3:20, ton coût API cible réaliste se situe autour de 3.2 à 4.3 USD/vidéo avec une structure Pro/Advanced.
|
||||||
|
3. Le vrai coût final dépendra du plan exact visible dans ton dashboard D-ID API (Launch/Scale possibles), et des éventuelles limites/overages contractuels.
|
||||||
|
|
||||||
|
## 6. Formule de recalcul immédiat (si ton plan API diffère)
|
||||||
|
|
||||||
|
Si ton plan API affiche `P` USD/mois pour `M` minutes incluses:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Coût vidéo (durée d secondes) = (P / M) x ceil(d / 60)
|
||||||
|
```
|
||||||
|
|
||||||
|
Exemple direct avec moyenne 2:55:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Coût vidéo moyen = (P / M) x 3
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
- D-ID Pricing Studio (FAQ: consommation API sur le même solde, arrondi minute): https://www.d-id.com/pricing/studio/
|
||||||
|
- D-ID droits d'usage (commercial vs personnel + mention Launch/Scale): https://help.d-id.com/hc/en-us/articles/31350883819537-What-are-the-copyright-and-commercial-use-rights-for-my-videos
|
||||||
|
- D-ID API credits endpoint: https://docs.d-id.com/reference/getcredits
|
||||||
Binary file not shown.
@@ -0,0 +1,158 @@
|
|||||||
|
# Emails existants MusicLand
|
||||||
|
|
||||||
|
Ce document liste uniquement les emails déjà présents comme emails dans l'application.
|
||||||
|
|
||||||
|
Les notifications applicatives qui peuvent être transformées en emails via `notifications/{notificationId}` sont exclues volontairement.
|
||||||
|
|
||||||
|
## Synthèse
|
||||||
|
|
||||||
|
| # | Email | Service | Déclencheur | Destinataire | Contenu maîtrisé dans le code |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | Bienvenue | Resend | Création d'un utilisateur | Nouvel utilisateur | Oui |
|
||||||
|
| 2 | Réinitialisation du mot de passe | Firebase Auth | Mot de passe oublié | Email saisi | Non, template Firebase |
|
||||||
|
| 3 | Vérification de nouvelle adresse email | Firebase Auth | Changement d'adresse email | Nouvelle adresse | Non, template Firebase |
|
||||||
|
|
||||||
|
## 1. Email de bienvenue
|
||||||
|
|
||||||
|
**Service**
|
||||||
|
Resend.
|
||||||
|
|
||||||
|
**Expéditeur**
|
||||||
|
`MusicLand <musicland@musicland.ai>`
|
||||||
|
|
||||||
|
**Déclencheur**
|
||||||
|
Création d'un document utilisateur `users/{userID}`.
|
||||||
|
|
||||||
|
**Pourquoi il est envoyé**
|
||||||
|
Accueillir l'utilisateur après son inscription sur MusicLand.
|
||||||
|
|
||||||
|
**Destinataire**
|
||||||
|
L'adresse stockée dans `users.email`.
|
||||||
|
|
||||||
|
**Sujet**
|
||||||
|
`Bienvenue sur MusicLand`
|
||||||
|
|
||||||
|
**Contenu exact du template**
|
||||||
|
|
||||||
|
| Zone | Contenu |
|
||||||
|
|---|---|
|
||||||
|
| Logo | Logo MusicLand en haut de l'email |
|
||||||
|
| Salutation | `Salut {firstName} {lastName},` si le prénom ou le nom existent |
|
||||||
|
| Salutation fallback | `Salut,` si aucun prénom/nom n'est disponible |
|
||||||
|
| Titre principal | `Bienvenue sur MusicLand` |
|
||||||
|
| Paragraphe 1 | `Nous sommes ravis de t'accueillir dans la communauté des artistes et passionnés de MusicLand.` |
|
||||||
|
| Paragraphe 2 | `Explore les playlists collaboratives, dépose tes projets et découvre ce que prépare la scène émergente.` |
|
||||||
|
| Signature | `L'équipe MusicLand` |
|
||||||
|
| Footer | `minuit.app · Paris, France` |
|
||||||
|
|
||||||
|
**Exemple de contenu**
|
||||||
|
|
||||||
|
```text
|
||||||
|
Salut Maya Durand,
|
||||||
|
|
||||||
|
Bienvenue sur MusicLand
|
||||||
|
|
||||||
|
Nous sommes ravis de t'accueillir dans la communauté des artistes et passionnés de MusicLand.
|
||||||
|
|
||||||
|
Explore les playlists collaboratives, dépose tes projets et découvre ce que prépare la scène émergente.
|
||||||
|
|
||||||
|
L'équipe MusicLand
|
||||||
|
|
||||||
|
minuit.app · Paris, France
|
||||||
|
```
|
||||||
|
|
||||||
|
**Variables utilisées**
|
||||||
|
|
||||||
|
| Variable | Source | Usage |
|
||||||
|
|---|---|---|
|
||||||
|
| `email` | Document utilisateur | Destinataire |
|
||||||
|
| `firstName` | Document utilisateur | Salutation personnalisée |
|
||||||
|
| `lastName` | Document utilisateur | Salutation personnalisée |
|
||||||
|
|
||||||
|
**Fichiers source**
|
||||||
|
|
||||||
|
- `functions/src/users.js`
|
||||||
|
- `functions/helpers/email.js`
|
||||||
|
|
||||||
|
## 2. Email de réinitialisation du mot de passe
|
||||||
|
|
||||||
|
**Service**
|
||||||
|
Firebase Auth.
|
||||||
|
|
||||||
|
**Déclencheur**
|
||||||
|
L'utilisateur renseigne son adresse email dans l'écran mot de passe oublié.
|
||||||
|
|
||||||
|
**Pourquoi il est envoyé**
|
||||||
|
Permettre à l'utilisateur de réinitialiser son mot de passe.
|
||||||
|
|
||||||
|
**Destinataire**
|
||||||
|
L'adresse email saisie par l'utilisateur.
|
||||||
|
|
||||||
|
**Sujet**
|
||||||
|
Non défini dans le repo. Le sujet est géré par le template Firebase Auth du projet.
|
||||||
|
|
||||||
|
**Contenu**
|
||||||
|
|
||||||
|
Le contenu exact n'est pas dans le code de l'application. Il est configuré côté Firebase Auth.
|
||||||
|
|
||||||
|
Le code déclenche seulement :
|
||||||
|
|
||||||
|
```js
|
||||||
|
firebase.auth().sendPasswordResetEmail(trimmedEmail)
|
||||||
|
```
|
||||||
|
|
||||||
|
Le contenu attendu du template Firebase est :
|
||||||
|
|
||||||
|
- un message indiquant qu'une demande de réinitialisation a été faite ;
|
||||||
|
- un lien de réinitialisation du mot de passe ;
|
||||||
|
- les instructions standard définies dans Firebase ;
|
||||||
|
- éventuellement le nom de l'application selon la configuration Firebase.
|
||||||
|
|
||||||
|
**Fichier source**
|
||||||
|
|
||||||
|
- `src/screens/ForgotPassword.js`
|
||||||
|
|
||||||
|
## 3. Email de vérification de nouvelle adresse email
|
||||||
|
|
||||||
|
**Service**
|
||||||
|
Firebase Auth.
|
||||||
|
|
||||||
|
**Déclencheur**
|
||||||
|
L'utilisateur demande à modifier son adresse email depuis son profil.
|
||||||
|
|
||||||
|
**Pourquoi il est envoyé**
|
||||||
|
Vérifier la nouvelle adresse email avant de finaliser le changement.
|
||||||
|
|
||||||
|
**Destinataire**
|
||||||
|
La nouvelle adresse email saisie par l'utilisateur.
|
||||||
|
|
||||||
|
**Sujet**
|
||||||
|
Non défini dans le repo. Le sujet est géré par le template Firebase Auth du projet.
|
||||||
|
|
||||||
|
**Contenu**
|
||||||
|
|
||||||
|
Le contenu exact n'est pas dans le code de l'application. Il est configuré côté Firebase Auth.
|
||||||
|
|
||||||
|
Le code déclenche :
|
||||||
|
|
||||||
|
```js
|
||||||
|
user.verifyBeforeUpdateEmail(val)
|
||||||
|
```
|
||||||
|
|
||||||
|
Le contenu attendu du template Firebase est :
|
||||||
|
|
||||||
|
- un message indiquant qu'une modification d'adresse email a été demandée ;
|
||||||
|
- un lien de vérification ;
|
||||||
|
- les instructions standard définies dans Firebase ;
|
||||||
|
- éventuellement le nom de l'application selon la configuration Firebase.
|
||||||
|
|
||||||
|
**Fichier source**
|
||||||
|
|
||||||
|
- `src/screens/Profile/ChangeEmailAddress.js`
|
||||||
|
|
||||||
|
## Point important
|
||||||
|
|
||||||
|
À partir du code du repo, le seul email dont le contenu HTML est entièrement maîtrisé dans l'application est l'email de bienvenue.
|
||||||
|
|
||||||
|
Les deux autres emails existent bien dans les parcours produit, mais leur sujet et leur contenu exact dépendent des templates configurés dans Firebase Auth.
|
||||||
|
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
# Roadmap Client - Playback IA MusicLand
|
||||||
|
|
||||||
|
## Vision
|
||||||
|
|
||||||
|
MusicLand doit permettre a chaque utilisateur d'interpreter sa propre chanson en playback, meme sans talent vocal, avec deux possibilites simples :
|
||||||
|
|
||||||
|
- se filmer en live ;
|
||||||
|
- generer une video IA a partir d'une seule photo.
|
||||||
|
|
||||||
|
L'objectif de cette version est de renforcer la creation de contenu sur MusicLand et de pousser les utilisateurs a publier leurs playbacks sur la plateforme.
|
||||||
|
|
||||||
|
L'aspect evenement physique / IRL n'est pas prevu pour cette version.
|
||||||
|
|
||||||
|
Le duo n'est pas prevu pour cette version.
|
||||||
|
|
||||||
|
## Ce Qui Change Pour L'utilisateur
|
||||||
|
|
||||||
|
L'etape playback devient plus riche, mais le parcours global de l'application reste le meme.
|
||||||
|
|
||||||
|
L'utilisateur cree toujours sa chanson, puis arrive a l'etape playback. A ce moment-la, il peut choisir entre deux options :
|
||||||
|
|
||||||
|
1. **Me filmer en live**
|
||||||
|
- L'utilisateur enregistre son playback avec sa camera.
|
||||||
|
- Il gagne des credits.
|
||||||
|
- Ce chemin est mis en avant, car il cree du contenu authentique pour la communaute.
|
||||||
|
|
||||||
|
2. **Generer mon playback avec IA**
|
||||||
|
- L'utilisateur ajoute une seule photo.
|
||||||
|
- Il decrit la scene souhaitee.
|
||||||
|
- MusicLand genere une video ou il est reconnaissable et fait le lip-sync sur toute la chanson.
|
||||||
|
- Ce chemin coute des credits.
|
||||||
|
|
||||||
|
Point important a afficher clairement dans l'interface :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Pour cette version, la video IA fonctionne avec une seule photo et une seule personne.
|
||||||
|
Il n'est pas possible d'ajouter deux images ou de generer un duo.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Role Des Avatars
|
||||||
|
|
||||||
|
Les avatars restent au coeur de l'experience.
|
||||||
|
|
||||||
|
Ils doivent guider l'utilisateur avec des videos courtes, motivantes et faciles a passer :
|
||||||
|
|
||||||
|
- avant le playback : donner envie de se lancer ;
|
||||||
|
- avant le live : conseiller sur le cadrage, la lumiere et le regard camera ;
|
||||||
|
- avant l'IA : expliquer comment choisir une bonne photo et une scene simple ;
|
||||||
|
- apres la creation : encourager la publication sur MusicLand.
|
||||||
|
|
||||||
|
Les avatars ne doivent pas ralentir le parcours. Ils doivent rassurer, conseiller et donner de l'energie.
|
||||||
|
|
||||||
|
## Flow Recommande
|
||||||
|
|
||||||
|
Le client souhaite garder une experience immersive, mais eviter les videos longues.
|
||||||
|
|
||||||
|
La recommandation est de remplacer les longues videos de transition par des interventions courtes et contextuelles.
|
||||||
|
|
||||||
|
### Principe
|
||||||
|
|
||||||
|
- pas de video longue obligatoire ;
|
||||||
|
- pas de tunnel coupe par trop d'explications ;
|
||||||
|
- des avatars visibles aux bons moments ;
|
||||||
|
- des messages courts ;
|
||||||
|
- une action claire a chaque ecran.
|
||||||
|
|
||||||
|
### Format recommande
|
||||||
|
|
||||||
|
1. **Micro-video avatar**
|
||||||
|
- 5 a 10 secondes ;
|
||||||
|
- skippable ;
|
||||||
|
- sous-titree ;
|
||||||
|
- lancee seulement aux moments importants.
|
||||||
|
|
||||||
|
2. **Conseil interactif**
|
||||||
|
- une phrase courte de l'avatar ;
|
||||||
|
- un bouton d'action direct ;
|
||||||
|
- exemple : `Choisis une photo nette, visage face camera`.
|
||||||
|
|
||||||
|
3. **Aide secondaire**
|
||||||
|
- les conseils detailles restent accessibles ;
|
||||||
|
- ils ne bloquent pas l'enregistrement ou la generation.
|
||||||
|
|
||||||
|
### Flow cible
|
||||||
|
|
||||||
|
```text
|
||||||
|
Choix Playback
|
||||||
|
-> Live ou IA
|
||||||
|
-> Conseil avatar court
|
||||||
|
-> Creation
|
||||||
|
-> Preview
|
||||||
|
-> Publier sur MusicLand
|
||||||
|
```
|
||||||
|
|
||||||
|
## Systeme De Credits Recommande
|
||||||
|
|
||||||
|
Il faut aligner le backend avec ce qui est affiche dans l'application.
|
||||||
|
|
||||||
|
Abonnements recommandes :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Pack Blue : 50 credits / mois
|
||||||
|
Pack Silver : 110 credits / mois
|
||||||
|
Pack Gold : 160 credits / mois
|
||||||
|
```
|
||||||
|
|
||||||
|
Achats ponctuels :
|
||||||
|
|
||||||
|
```text
|
||||||
|
30 credits = 4,99 EUR
|
||||||
|
100 credits = 9,99 EUR
|
||||||
|
150 credits = 14,99 EUR
|
||||||
|
```
|
||||||
|
|
||||||
|
Couts recommandes :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Generation musique : 8 credits
|
||||||
|
Playback live : +3 credits gagnes
|
||||||
|
Playback IA : 60 credits
|
||||||
|
Export prive : +20 credits ou reserve aux abonnes
|
||||||
|
```
|
||||||
|
|
||||||
|
Le bonus live doit rester motivant, mais limite pour eviter les abus :
|
||||||
|
|
||||||
|
- 1 bonus live par projet ;
|
||||||
|
- 1 a 2 bonus live maximum par jour ;
|
||||||
|
- bonus donne uniquement apres creation reussie de la video.
|
||||||
|
|
||||||
|
## Playback IA
|
||||||
|
|
||||||
|
Pour la premiere version, le plus fiable est de faire une video simple :
|
||||||
|
|
||||||
|
- visage reconnaissable ;
|
||||||
|
- une seule personne ;
|
||||||
|
- une seule photo ;
|
||||||
|
- plan portrait ou buste ;
|
||||||
|
- regard camera ;
|
||||||
|
- mouvement leger ;
|
||||||
|
- lip-sync precis ;
|
||||||
|
- duree de toute la chanson.
|
||||||
|
|
||||||
|
Il ne faut pas viser tout de suite une video avec danse, corps entier ou mise en scene complexe. Cela augmenterait le cout et le risque de mauvais resultat.
|
||||||
|
|
||||||
|
Il faut aussi exclure clairement :
|
||||||
|
|
||||||
|
- deux images ;
|
||||||
|
- duo ;
|
||||||
|
- groupe ;
|
||||||
|
- celebrites ;
|
||||||
|
- photo d'un tiers sans autorisation.
|
||||||
|
|
||||||
|
## Solutions IA Recommandees
|
||||||
|
|
||||||
|
Le besoin MusicLand est specifique : une photo, une chanson complete, un utilisateur reconnaissable, et un lip-sync precis.
|
||||||
|
|
||||||
|
### Recommandation principale : D-ID
|
||||||
|
|
||||||
|
D-ID est la meilleure option pour demarrer.
|
||||||
|
|
||||||
|
Pourquoi :
|
||||||
|
|
||||||
|
- adapte a une photo + audio ;
|
||||||
|
- compatible avec une chanson complete ;
|
||||||
|
- bon rapport simplicite / cout ;
|
||||||
|
- moderation image et audio integree ;
|
||||||
|
- delai annonce compatible avec une experience de quelques minutes.
|
||||||
|
|
||||||
|
Limite :
|
||||||
|
|
||||||
|
- rendu plutot portrait / talking head ;
|
||||||
|
- moins adapte aux clips avec danse, corps entier ou scenes tres dynamiques.
|
||||||
|
|
||||||
|
### Option a tester en parallele : Sync Labs
|
||||||
|
|
||||||
|
Sync Labs est interessant si l'objectif prioritaire devient la qualite du lip-sync.
|
||||||
|
|
||||||
|
Pourquoi :
|
||||||
|
|
||||||
|
- specialise dans le lip-sync ;
|
||||||
|
- accepte image ou video + audio ;
|
||||||
|
- bon candidat pour comparer la precision bouche / audio.
|
||||||
|
|
||||||
|
Limite :
|
||||||
|
|
||||||
|
- cout plus eleve ;
|
||||||
|
- necessite des tests qualite sur des chansons completes.
|
||||||
|
|
||||||
|
### Option premium : HeyGen
|
||||||
|
|
||||||
|
HeyGen peut etre interessant si le client veut un rendu avatar plus premium.
|
||||||
|
|
||||||
|
Pourquoi :
|
||||||
|
|
||||||
|
- rendu plus propre visuellement ;
|
||||||
|
- ecosysteme avatar mature ;
|
||||||
|
- bon pour videos avatar commerciales.
|
||||||
|
|
||||||
|
Limite :
|
||||||
|
|
||||||
|
- cout plus eleve sur une chanson complete ;
|
||||||
|
- moins prioritaire pour un MVP MusicLand.
|
||||||
|
|
||||||
|
### Solutions non prioritaires pour cette V1
|
||||||
|
|
||||||
|
Les generateurs video generalistes, comme Sora ou autres modeles prompt-to-video, ne sont pas le bon choix principal pour cette version.
|
||||||
|
|
||||||
|
Raison :
|
||||||
|
|
||||||
|
- durees souvent trop courtes ;
|
||||||
|
- lip-sync chanson complete plus difficile ;
|
||||||
|
- controle de l'identite moins fiable ;
|
||||||
|
- cout potentiellement trop eleve pour une fonctionnalite grand public.
|
||||||
|
|
||||||
|
## Point De Cadrage Video
|
||||||
|
|
||||||
|
Pour eviter les malentendus, l'ecran IA doit expliquer simplement la limite :
|
||||||
|
|
||||||
|
```text
|
||||||
|
La generation IA utilise une seule photo.
|
||||||
|
Pour cette version, les duos et les videos avec deux personnes ne sont pas encore disponibles.
|
||||||
|
```
|
||||||
|
|
||||||
|
Cette limite est importante pour trois raisons :
|
||||||
|
|
||||||
|
- meilleur controle de la qualite ;
|
||||||
|
- cout plus previsible ;
|
||||||
|
- moins de risques de droits a l'image.
|
||||||
|
|
||||||
|
## Publication Sur MusicLand
|
||||||
|
|
||||||
|
La publication doit devenir le chemin principal.
|
||||||
|
|
||||||
|
Apres creation du playback, l'ecran doit pousser l'utilisateur vers :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Publier sur MusicLand et recuperer ma video HD
|
||||||
|
```
|
||||||
|
|
||||||
|
Les autres options doivent rester secondaires :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Garder en brouillon
|
||||||
|
Telecharger sans publier
|
||||||
|
```
|
||||||
|
|
||||||
|
Le telechargement sans publication ne doit pas etre trop avantageux, sinon l'utilisateur repart avec son fichier sans enrichir la plateforme.
|
||||||
|
|
||||||
|
Recommandation :
|
||||||
|
|
||||||
|
- publication = video HD incluse ;
|
||||||
|
- brouillon = conserve dans l'app ;
|
||||||
|
- telechargement prive = payant ou reserve aux abonnes.
|
||||||
|
|
||||||
|
## Securite Et Consentement
|
||||||
|
|
||||||
|
Avant toute generation IA, il faut demander des engagements clairs :
|
||||||
|
|
||||||
|
- utilisateur majeur uniquement ;
|
||||||
|
- consentement obligatoire ;
|
||||||
|
- interdiction d'utiliser la photo d'une celebrite ;
|
||||||
|
- interdiction d'utiliser la photo d'un tiers sans autorisation ;
|
||||||
|
- possibilite de signaler une video ;
|
||||||
|
- suppression possible sur demande.
|
||||||
|
|
||||||
|
## Roadmap De Livraison
|
||||||
|
|
||||||
|
### Lot 1 - Credits Et Regles Produit
|
||||||
|
|
||||||
|
Objectif : stabiliser le modele economique.
|
||||||
|
|
||||||
|
A livrer :
|
||||||
|
|
||||||
|
- aligner les credits des abonnements ;
|
||||||
|
- figer le cout du live et de l'IA ;
|
||||||
|
- definir les limites anti-abus ;
|
||||||
|
- clarifier le telechargement prive.
|
||||||
|
|
||||||
|
### Lot 2 - Choix Live Ou IA
|
||||||
|
|
||||||
|
Objectif : rendre l'etape playback plus claire.
|
||||||
|
|
||||||
|
A livrer :
|
||||||
|
|
||||||
|
- nouvel ecran de choix ;
|
||||||
|
- option live mise en avant ;
|
||||||
|
- option IA accessible ;
|
||||||
|
- avatar de conseil court ;
|
||||||
|
- message clair sur les credits gagnes ou depenses.
|
||||||
|
- message clair : `une seule photo pour la video IA`.
|
||||||
|
|
||||||
|
### Lot 3 - Playback Live Recompense
|
||||||
|
|
||||||
|
Objectif : encourager les utilisateurs a se filmer.
|
||||||
|
|
||||||
|
A livrer :
|
||||||
|
|
||||||
|
- enregistrement live ;
|
||||||
|
- preview ;
|
||||||
|
- bonus de +3 credits ;
|
||||||
|
- incitation forte a publier ;
|
||||||
|
- limites anti-abus.
|
||||||
|
|
||||||
|
### Lot 4 - Playback IA
|
||||||
|
|
||||||
|
Objectif : permettre de creer un playback IA a partir d'une photo.
|
||||||
|
|
||||||
|
A livrer :
|
||||||
|
|
||||||
|
- upload photo ;
|
||||||
|
- description de scene ;
|
||||||
|
- consentement ;
|
||||||
|
- paiement en credits ;
|
||||||
|
- generation video ;
|
||||||
|
- notification quand la video est prete ;
|
||||||
|
- preview ;
|
||||||
|
- publication sur MusicLand.
|
||||||
|
|
||||||
|
### Lot 5 - Optimisation Du Flow Immersif
|
||||||
|
|
||||||
|
Objectif : garder l'aspect guide par avatars sans imposer de longues videos.
|
||||||
|
|
||||||
|
A livrer :
|
||||||
|
|
||||||
|
- videos avatars plus courtes ;
|
||||||
|
- videos skippables ;
|
||||||
|
- conseils contextuels ;
|
||||||
|
- aide secondaire accessible ;
|
||||||
|
- moins de transitions bloquantes.
|
||||||
|
|
||||||
|
### Lot 6 - Feed Et Engagement
|
||||||
|
|
||||||
|
Objectif : donner de la valeur a la publication.
|
||||||
|
|
||||||
|
A livrer :
|
||||||
|
|
||||||
|
- feed playback plus clair ;
|
||||||
|
- likes et commentaires visibles ;
|
||||||
|
- notifications d'activite ;
|
||||||
|
- bouton pour refaire son propre playback sur une chanson ;
|
||||||
|
- mise en avant des meilleurs playbacks.
|
||||||
|
|
||||||
|
## Priorites
|
||||||
|
|
||||||
|
Ordre conseille :
|
||||||
|
|
||||||
|
1. Stabiliser les credits.
|
||||||
|
2. Ajouter le choix live / IA.
|
||||||
|
3. Recompense live.
|
||||||
|
4. Publication MusicLand plus incitative.
|
||||||
|
5. Generation IA.
|
||||||
|
6. Flow immersif avec avatars courts.
|
||||||
|
7. Feed et engagement.
|
||||||
|
|
||||||
|
## Synthese
|
||||||
|
|
||||||
|
La bonne V1 n'est pas un clip IA complexe.
|
||||||
|
|
||||||
|
La bonne V1 est :
|
||||||
|
|
||||||
|
- un playback live recompense ;
|
||||||
|
- un playback IA simple, reconnaissable et fiable ;
|
||||||
|
- des avatars courts qui guident ;
|
||||||
|
- une publication MusicLand mise en avant.
|
||||||
|
|
||||||
|
Ce plan garde l'application simple tout en ajoutant une vraie nouveaute produit et une meilleure incitation a publier sur MusicLand.
|
||||||
|
|
||||||
|
## Sources De Reference IA
|
||||||
|
|
||||||
|
- D-ID API : https://docs.d-id.com/reference/createtalk
|
||||||
|
- D-ID FAQ credits et moderation : https://www.d-id.com/faqs/
|
||||||
|
- Sync Labs API : https://sync.so/docs/api-reference/api-overview
|
||||||
|
- Sync Labs billing : https://sync.so/docs/product/billing
|
||||||
|
- HeyGen pricing API : https://developers.heygen.com/docs/pricing
|
||||||
|
- OpenAI Videos API : https://platform.openai.com/docs/api-reference/videos
|
||||||
@@ -0,0 +1,540 @@
|
|||||||
|
# Roadmap Playback IA MusicLand
|
||||||
|
|
||||||
|
## Objectif V1
|
||||||
|
|
||||||
|
Faire de MusicLand une plateforme ou l'utilisateur peut creer un playback, etre guide par des avatars, choisir entre un playback live ou un playback genere par IA, puis etre fortement incite a publier sa video sur MusicLand.
|
||||||
|
|
||||||
|
L'objectif IRL n'est pas retenu pour cette version.
|
||||||
|
|
||||||
|
## Principes Produit
|
||||||
|
|
||||||
|
### Deux chemins de creation
|
||||||
|
|
||||||
|
1. **Playback live**
|
||||||
|
- L'utilisateur se filme avec la camera.
|
||||||
|
- C'est le chemin a pousser en priorite, car il cree du contenu authentique pour la communaute.
|
||||||
|
- L'utilisateur gagne des credits des que la video est enregistree.
|
||||||
|
|
||||||
|
2. **Playback IA**
|
||||||
|
- L'utilisateur fournit une seule photo.
|
||||||
|
- Il decrit la scene souhaitee.
|
||||||
|
- MusicLand genere une video ou l'utilisateur est reconnaissable et fait le lip-sync sur toute la chanson.
|
||||||
|
- Ce chemin coute des credits.
|
||||||
|
|
||||||
|
### Limite V1 Video IA
|
||||||
|
|
||||||
|
Pour cette version, la video IA fonctionne avec une seule photo et une seule personne.
|
||||||
|
|
||||||
|
Il faut l'indiquer clairement dans l'interface :
|
||||||
|
|
||||||
|
```text
|
||||||
|
La generation IA utilise une seule photo.
|
||||||
|
Les duos et videos avec deux personnes ne sont pas disponibles dans cette version.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Phase 0 - Cadrage Et Corrections Prealables
|
||||||
|
|
||||||
|
**Duree estimee : 2 a 4 jours**
|
||||||
|
|
||||||
|
### Corriger le modele de credits
|
||||||
|
|
||||||
|
Il existe une incoherence entre l'UI et le backend :
|
||||||
|
|
||||||
|
- UI : 50 / 110 / 160 credits par abonnement ;
|
||||||
|
- backend : 10 / 40 / 60 credits.
|
||||||
|
|
||||||
|
Decision recommandee :
|
||||||
|
|
||||||
|
- aligner le backend sur l'UI ;
|
||||||
|
- conserver 50 / 110 / 160 credits par abonnement ;
|
||||||
|
- considerer qu'un credit vaut environ 0,10 EUR pour les arbitrages produit.
|
||||||
|
|
||||||
|
Raison :
|
||||||
|
|
||||||
|
- c'est deja ce que le client et l'utilisateur voient ;
|
||||||
|
- les abonnements deviennent plus attractifs ;
|
||||||
|
- le volume de credits permet d'integrer l'IA sans bloquer trop vite l'utilisateur ;
|
||||||
|
- 10 / 40 / 60 credits est trop faible si un playback IA coute environ 60 credits.
|
||||||
|
|
||||||
|
Barème recommande :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Pack Blue : 50 credits / mois
|
||||||
|
Pack Silver : 110 credits / mois
|
||||||
|
Pack Gold : 160 credits / mois
|
||||||
|
```
|
||||||
|
|
||||||
|
Achats ponctuels existants :
|
||||||
|
|
||||||
|
```text
|
||||||
|
30 credits = 4,99 EUR
|
||||||
|
100 credits = 9,99 EUR
|
||||||
|
150 credits = 14,99 EUR
|
||||||
|
```
|
||||||
|
|
||||||
|
Couts officiels recommandes :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Generation musique : 8 credits
|
||||||
|
Playback live : +3 credits gagnes
|
||||||
|
Playback IA : 60 credits
|
||||||
|
Export prive : +20 credits ou reserve aux abonnes
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ajouter les regles anti-abus
|
||||||
|
|
||||||
|
Actions :
|
||||||
|
|
||||||
|
- limiter le bonus live a une fois par projet ;
|
||||||
|
- ajouter une limite journaliere de 1 a 2 bonus live maximum ;
|
||||||
|
- accorder le bonus uniquement apres fusion video reussie ;
|
||||||
|
- ajouter des types d'operations clairs :
|
||||||
|
- `LIVE_PLAYBACK_REWARD` ;
|
||||||
|
- `AI_PLAYBACK_SPEND` ;
|
||||||
|
- `AI_PLAYBACK_REFUND` ;
|
||||||
|
- `PRIVATE_EXPORT_SPEND`.
|
||||||
|
|
||||||
|
Regle recommandee :
|
||||||
|
|
||||||
|
```text
|
||||||
|
1 bonus live par projet
|
||||||
|
1 a 2 bonus live maximum par jour
|
||||||
|
bonus uniquement apres fusion video reussie
|
||||||
|
```
|
||||||
|
|
||||||
|
Le bonus live doit rester motivant, mais ne doit pas devenir un moyen de farmer des credits. Le bon niveau pour la V1 est donc +3 credits, pas +5.
|
||||||
|
|
||||||
|
## Phase 1 - Nouveau Modele De Donnees Playback
|
||||||
|
|
||||||
|
**Duree estimee : 4 a 6 jours**
|
||||||
|
|
||||||
|
Aujourd'hui, un projet ne porte qu'un seul `playbackUrl`. Ce modele bloque :
|
||||||
|
|
||||||
|
- plusieurs playbacks sur une meme chanson ;
|
||||||
|
- les brouillons ;
|
||||||
|
- les generations IA ;
|
||||||
|
- les statistiques propres a chaque performance.
|
||||||
|
|
||||||
|
### Nouvelle collection : `playbackPerformances`
|
||||||
|
|
||||||
|
Structure recommandee :
|
||||||
|
|
||||||
|
```js
|
||||||
|
{
|
||||||
|
projectId,
|
||||||
|
userId,
|
||||||
|
type: 'live' | 'ai_solo',
|
||||||
|
status: 'draft' | 'processing' | 'ready' | 'published' | 'failed',
|
||||||
|
videoUrl,
|
||||||
|
sourceVideoUrl,
|
||||||
|
aiProvider,
|
||||||
|
aiJobId,
|
||||||
|
photoUrl,
|
||||||
|
consentAcceptedAt,
|
||||||
|
prompt,
|
||||||
|
creditsCost,
|
||||||
|
creditsReward,
|
||||||
|
isPublished,
|
||||||
|
createdAt,
|
||||||
|
publishedAt,
|
||||||
|
likesCount,
|
||||||
|
commentsCount,
|
||||||
|
viewsCount
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Actions :
|
||||||
|
|
||||||
|
- creer la collection ;
|
||||||
|
- migrer progressivement l'usage de `project.playbackUrl` ;
|
||||||
|
- garder `project.playbackUrl` temporairement pour compatibilite ;
|
||||||
|
- faire lire le feed playback depuis `playbackPerformances`.
|
||||||
|
|
||||||
|
## Phase 2 - Nouvel Ecran De Choix Playback
|
||||||
|
|
||||||
|
**Duree estimee : 4 a 5 jours**
|
||||||
|
|
||||||
|
L'ecran actuel propose principalement l'enregistrement live. Il faut ajouter un vrai choix produit.
|
||||||
|
|
||||||
|
### Nouvel ecran
|
||||||
|
|
||||||
|
Actions :
|
||||||
|
|
||||||
|
- CTA principal : `Me filmer en live et gagner des credits` ;
|
||||||
|
- CTA secondaire : `Generer mon playback avec IA` ;
|
||||||
|
- lien : `Conseils de Theo` ;
|
||||||
|
- video avatar courte, motivante, skippable.
|
||||||
|
- message clair : `une seule photo pour la video IA`.
|
||||||
|
|
||||||
|
### Regle UX importante
|
||||||
|
|
||||||
|
Ne pas redemander l'emotion. L'utilisateur a deja choisi l'emotion et la musique avant d'arriver au playback.
|
||||||
|
|
||||||
|
Les avatars doivent conseiller sur l'interpretation :
|
||||||
|
|
||||||
|
- cadrage ;
|
||||||
|
- lumiere ;
|
||||||
|
- regard camera ;
|
||||||
|
- energie ;
|
||||||
|
- intention.
|
||||||
|
|
||||||
|
## Phase 3 - Playback Live Recompense
|
||||||
|
|
||||||
|
**Duree estimee : 5 a 7 jours**
|
||||||
|
|
||||||
|
### Parcours
|
||||||
|
|
||||||
|
1. L'utilisateur choisit le live.
|
||||||
|
2. Il enregistre son playback.
|
||||||
|
3. L'app cree une `playbackPerformance` en brouillon.
|
||||||
|
4. La video est fusionnee avec l'audio.
|
||||||
|
5. L'utilisateur recoit ses credits.
|
||||||
|
6. Il arrive sur la preview.
|
||||||
|
7. L'app pousse la publication MusicLand.
|
||||||
|
|
||||||
|
### Actions techniques
|
||||||
|
|
||||||
|
- creer une performance `live` apres enregistrement ;
|
||||||
|
- fusionner video + musique comme aujourd'hui ;
|
||||||
|
- accorder le bonus credit cote backend ;
|
||||||
|
- afficher un retour immediat : `+3 credits gagnes` ;
|
||||||
|
- empecher les recompenses multiples abusives.
|
||||||
|
|
||||||
|
### UX apres enregistrement
|
||||||
|
|
||||||
|
CTA principal :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Publier sur MusicLand
|
||||||
|
```
|
||||||
|
|
||||||
|
CTA secondaire :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Recommencer
|
||||||
|
```
|
||||||
|
|
||||||
|
CTA tertiaire :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Garder en brouillon
|
||||||
|
```
|
||||||
|
|
||||||
|
## Phase 4 - Playback IA
|
||||||
|
|
||||||
|
**Duree estimee : 8 a 12 jours**
|
||||||
|
|
||||||
|
### Provider recommande V1
|
||||||
|
|
||||||
|
Provider recommande : **D-ID**.
|
||||||
|
|
||||||
|
Raison :
|
||||||
|
|
||||||
|
- adapte a une photo + audio ;
|
||||||
|
- compatible avec une chanson complete jusqu'a 5 minutes ;
|
||||||
|
- plus simple pour une V1 ;
|
||||||
|
- cout plus realiste que les solutions video premium.
|
||||||
|
|
||||||
|
Limite assumee :
|
||||||
|
|
||||||
|
- rendu prioritairement portrait ou buste ;
|
||||||
|
- une seule photo ;
|
||||||
|
- une seule personne ;
|
||||||
|
- pas de duo ;
|
||||||
|
- pas de corps entier ou danse complexe en V1.
|
||||||
|
|
||||||
|
### Parcours IA
|
||||||
|
|
||||||
|
1. L'utilisateur choisit `Generer avec IA`.
|
||||||
|
2. Il upload une seule photo.
|
||||||
|
3. Il accepte les regles :
|
||||||
|
- utilisateur majeur ;
|
||||||
|
- consentement obligatoire ;
|
||||||
|
- interdiction d'utiliser une celebrite ;
|
||||||
|
- interdiction d'utiliser un tiers sans autorisation.
|
||||||
|
4. Il decrit la scene.
|
||||||
|
5. L'app affiche le cout en credits.
|
||||||
|
6. Le backend debite les credits.
|
||||||
|
7. Le backend lance la generation IA.
|
||||||
|
8. L'utilisateur voit un statut `generation en cours`.
|
||||||
|
9. L'app notifie quand la video est prete.
|
||||||
|
10. L'utilisateur previsualise.
|
||||||
|
11. L'app pousse la publication MusicLand.
|
||||||
|
|
||||||
|
### Mouvement V1 recommande
|
||||||
|
|
||||||
|
Pour maximiser la faisabilite :
|
||||||
|
|
||||||
|
- portrait ou buste ;
|
||||||
|
- face camera ;
|
||||||
|
- mouvement leger ;
|
||||||
|
- decor simple ;
|
||||||
|
- pas de danse complexe ;
|
||||||
|
- pas de full-body ;
|
||||||
|
- pas de camera dynamique.
|
||||||
|
|
||||||
|
### Prompt utilisateur
|
||||||
|
|
||||||
|
Champ simple :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Decris la scene de ton playback
|
||||||
|
```
|
||||||
|
|
||||||
|
Exemples :
|
||||||
|
|
||||||
|
- `sur scene avec des lumieres rouges` ;
|
||||||
|
- `dans une chambre intime, ambiance triste` ;
|
||||||
|
- `clip pop lumineux, fond violet`.
|
||||||
|
|
||||||
|
## Phase 5 - Limites Video IA V1
|
||||||
|
|
||||||
|
**Statut : hors scope V1**
|
||||||
|
|
||||||
|
Le duo n'est pas prevu pour cette version. Le flow IA doit rester limite a une seule photo et une seule personne.
|
||||||
|
|
||||||
|
Il ne faut donc pas ouvrir de parcours permettant d'ajouter une deuxieme image ou de creer une video avec deux personnes.
|
||||||
|
|
||||||
|
Message produit a afficher cote IA :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Pour cette version, la video IA fonctionne avec une seule photo.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Phase 6 - Solutions IA A Tester
|
||||||
|
|
||||||
|
**Duree estimee : 3 a 5 jours de benchmark**
|
||||||
|
|
||||||
|
Le besoin MusicLand est specifique : photo unique, chanson complete, utilisateur reconnaissable, lip-sync precis.
|
||||||
|
|
||||||
|
### Option 1 - D-ID
|
||||||
|
|
||||||
|
Statut : recommandation principale pour le MVP.
|
||||||
|
|
||||||
|
Raison :
|
||||||
|
|
||||||
|
- endpoint adapte a une image source et un audio ;
|
||||||
|
- logique de talking head compatible avec le playback ;
|
||||||
|
- moderation image/audio deja prevue ;
|
||||||
|
- cout et integration plus accessibles pour demarrer.
|
||||||
|
|
||||||
|
### Option 2 - Sync Labs
|
||||||
|
|
||||||
|
Statut : challenger a tester.
|
||||||
|
|
||||||
|
Raison :
|
||||||
|
|
||||||
|
- specialiste du lip-sync ;
|
||||||
|
- accepte image ou video + audio ;
|
||||||
|
- interessant si la precision bouche/audio devient le critere principal.
|
||||||
|
|
||||||
|
Limite :
|
||||||
|
|
||||||
|
- cout plus eleve ;
|
||||||
|
- benchmark obligatoire sur plusieurs chansons completes.
|
||||||
|
|
||||||
|
### Option 3 - HeyGen
|
||||||
|
|
||||||
|
Statut : option premium.
|
||||||
|
|
||||||
|
Raison :
|
||||||
|
|
||||||
|
- rendu avatar propre ;
|
||||||
|
- ecosysteme mature.
|
||||||
|
|
||||||
|
Limite :
|
||||||
|
|
||||||
|
- cout plus eleve pour une chanson complete ;
|
||||||
|
- moins adapte comme premier choix MVP grand public.
|
||||||
|
|
||||||
|
### Non prioritaire - Generateurs video generalistes
|
||||||
|
|
||||||
|
Sora et les autres generateurs prompt-to-video ne sont pas le bon choix principal pour cette V1.
|
||||||
|
|
||||||
|
Raison :
|
||||||
|
|
||||||
|
- duree des clips trop courte pour une chanson complete ;
|
||||||
|
- lip-sync chanson complete moins fiable ;
|
||||||
|
- identite utilisateur moins controllable ;
|
||||||
|
- cout plus difficile a maitriser.
|
||||||
|
|
||||||
|
## Phase 7 - Avatars Guides Et Flow Court
|
||||||
|
|
||||||
|
**Duree estimee : 4 a 6 jours, en parallele**
|
||||||
|
|
||||||
|
Le client veut conserver l'aspect guide par avatars, mais sans longues videos de transition.
|
||||||
|
|
||||||
|
La logique recommandee est de remplacer les videos longues par des interventions courtes, contextuelles et skippables.
|
||||||
|
|
||||||
|
### Videos avatars a prevoir
|
||||||
|
|
||||||
|
- avant playback : motivation et intention ;
|
||||||
|
- avant live : cadrage, lumiere, regard camera ;
|
||||||
|
- avant IA : photo claire, visage face camera, scene simple ;
|
||||||
|
- apres creation : incitation a publier ;
|
||||||
|
- apres publication : felicitation et partage.
|
||||||
|
|
||||||
|
### Regles UX
|
||||||
|
|
||||||
|
- videos preregistrees identiques pour tous ;
|
||||||
|
- duree cible : 5 a 10 secondes ;
|
||||||
|
- skippables ;
|
||||||
|
- sous-titres ;
|
||||||
|
- ne pas les relancer a chaque fois ;
|
||||||
|
- memoriser les videos vues dans `seenAvatarVideos`.
|
||||||
|
|
||||||
|
Objectif : guider sans ralentir le parcours.
|
||||||
|
|
||||||
|
### Flow cible
|
||||||
|
|
||||||
|
```text
|
||||||
|
Choix Playback
|
||||||
|
-> Live ou IA
|
||||||
|
-> Conseil avatar court
|
||||||
|
-> Creation
|
||||||
|
-> Preview
|
||||||
|
-> Publier sur MusicLand
|
||||||
|
```
|
||||||
|
|
||||||
|
## Phase 8 - Incitation A Publier Sur MusicLand
|
||||||
|
|
||||||
|
**Duree estimee : 5 a 8 jours**
|
||||||
|
|
||||||
|
Le produit doit rendre la publication plus desirable que le telechargement direct.
|
||||||
|
|
||||||
|
### Regle produit
|
||||||
|
|
||||||
|
Publication MusicLand = chemin principal.
|
||||||
|
|
||||||
|
Telechargement prive = possible, mais moins avantageux.
|
||||||
|
|
||||||
|
### Ecran de fin recommande
|
||||||
|
|
||||||
|
CTA principal :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Publier sur MusicLand et recuperer ma video HD
|
||||||
|
```
|
||||||
|
|
||||||
|
CTA secondaire :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Garder en brouillon
|
||||||
|
```
|
||||||
|
|
||||||
|
CTA tertiaire discret :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Telecharger sans publier
|
||||||
|
```
|
||||||
|
|
||||||
|
### Incitations
|
||||||
|
|
||||||
|
- publication = video HD incluse ;
|
||||||
|
- publication = visibilite dans le feed ;
|
||||||
|
- publication = likes et commentaires ;
|
||||||
|
- publication = badge ou petit bonus credit ;
|
||||||
|
- non publie = brouillon prive ;
|
||||||
|
- telechargement sans publication = payant ou reserve aux abonnes.
|
||||||
|
|
||||||
|
## Phase 9 - Feed Playback Et Validation Sociale
|
||||||
|
|
||||||
|
**Duree estimee : 8 a 12 jours**
|
||||||
|
|
||||||
|
Le feed doit devenir un vrai moteur communautaire.
|
||||||
|
|
||||||
|
### Actions
|
||||||
|
|
||||||
|
- lire les performances publiees depuis `playbackPerformances` ;
|
||||||
|
- ajouter tri :
|
||||||
|
- recent ;
|
||||||
|
- populaire ;
|
||||||
|
- abonnements ;
|
||||||
|
- compter les vues playback reellement ;
|
||||||
|
- liker/commenter une performance, pas seulement un projet ;
|
||||||
|
- ajouter un vrai ecran activite ;
|
||||||
|
- ajouter `Faire mon playback sur cette chanson` ;
|
||||||
|
- ajouter `Repondre en playback`.
|
||||||
|
|
||||||
|
Objectif : chaque playback publie doit donner envie a quelqu'un d'autre d'en faire un.
|
||||||
|
|
||||||
|
## Phase 10 - Moderation, Consentement Et Securite
|
||||||
|
|
||||||
|
**Duree estimee : 5 a 8 jours**
|
||||||
|
|
||||||
|
Obligatoire avant de publier des videos IA.
|
||||||
|
|
||||||
|
### Actions
|
||||||
|
|
||||||
|
- consentement photo obligatoire ;
|
||||||
|
- stockage `consentAcceptedAt` ;
|
||||||
|
- confirmation utilisateur majeur ;
|
||||||
|
- interdiction celebrites ;
|
||||||
|
- interdiction tiers sans autorisation ;
|
||||||
|
- signalement playback IA ;
|
||||||
|
- suppression video ;
|
||||||
|
- logs provider ;
|
||||||
|
- remboursement automatique si generation echoue ;
|
||||||
|
- moderation photo/audio via provider et regles internes.
|
||||||
|
|
||||||
|
## Phase 11 - Analytics Produit
|
||||||
|
|
||||||
|
**Duree estimee : 3 a 5 jours**
|
||||||
|
|
||||||
|
### Evenements a tracker
|
||||||
|
|
||||||
|
```text
|
||||||
|
playback_live_started
|
||||||
|
playback_live_recorded
|
||||||
|
playback_live_published
|
||||||
|
ai_playback_started
|
||||||
|
ai_photo_uploaded
|
||||||
|
ai_generation_paid
|
||||||
|
ai_generation_ready
|
||||||
|
ai_playback_published
|
||||||
|
download_without_publish
|
||||||
|
publish_after_preview
|
||||||
|
feed_playback_view
|
||||||
|
playback_like
|
||||||
|
playback_comment
|
||||||
|
```
|
||||||
|
|
||||||
|
### KPIs V1
|
||||||
|
|
||||||
|
- taux de publication apres creation ;
|
||||||
|
- cout moyen par generation IA ;
|
||||||
|
- taux d'echec IA ;
|
||||||
|
- part des videos telechargees sans publication ;
|
||||||
|
- nombre de playbacks publies par chanson ;
|
||||||
|
- delai moyen de generation IA ;
|
||||||
|
- ratio live vs IA ;
|
||||||
|
- taux de transformation preview vers publication.
|
||||||
|
|
||||||
|
## Priorite De Developpement
|
||||||
|
|
||||||
|
Ordre conseille :
|
||||||
|
|
||||||
|
1. Corriger les credits.
|
||||||
|
2. Creer `playbackPerformances`.
|
||||||
|
3. Refaire l'ecran choix live / IA.
|
||||||
|
4. Ajouter la recompense live.
|
||||||
|
5. Ajouter la publication incitative.
|
||||||
|
6. Integrer l'IA D-ID.
|
||||||
|
7. Ajouter les notifications de generation.
|
||||||
|
8. Optimiser les avatars courts et les flows sans videos longues.
|
||||||
|
9. Refaire le feed playback.
|
||||||
|
10. Ajouter analytics et moderation complete.
|
||||||
|
|
||||||
|
## Synthese
|
||||||
|
|
||||||
|
La V1 ne doit pas chercher a produire un clip IA parfait avec corps entier, danse et mise en scene complexe.
|
||||||
|
|
||||||
|
Le meilleur MVP est :
|
||||||
|
|
||||||
|
- playback live recompense ;
|
||||||
|
- playback IA depuis photo avec lip-sync sur chanson complete ;
|
||||||
|
- avatars courts pour guider ;
|
||||||
|
- publication MusicLand comme chemin principal ;
|
||||||
|
- telechargement possible mais moins incitatif.
|
||||||
|
|
||||||
|
Ce cadrage donne le meilleur equilibre entre faisabilite technique, cout IA, monetisation et creation de contenu sur MusicLand.
|
||||||
@@ -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
|
||||||
+94
-34
@@ -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,
|
||||||
system,
|
modelNames: TEXT_MODEL_NAMES,
|
||||||
prompt,
|
label: 'generateAI',
|
||||||
output: { schema },
|
request: {
|
||||||
config: {
|
system,
|
||||||
temperature: 0.7, // Créativité équilibrée
|
prompt,
|
||||||
|
output: { schema },
|
||||||
|
config: {
|
||||||
|
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,34 +187,36 @@ ${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,
|
||||||
system,
|
modelNames: MODERATION_MODEL_NAMES,
|
||||||
prompt: userPrompt,
|
label: 'analyseLyrics',
|
||||||
output: { schema: moderationSchema },
|
request: {
|
||||||
config: {
|
system,
|
||||||
// Paramètres de sécurité permissifs pour laisser l'IA voir et juger le contenu
|
prompt: userPrompt,
|
||||||
safetySettings: [
|
output: { schema: moderationSchema },
|
||||||
{ category: 'HARM_CATEGORY_HATE_SPEECH', threshold: 'BLOCK_NONE' },
|
config: {
|
||||||
{
|
// Paramètres de sécurité permissifs pour laisser l'IA voir et juger le contenu
|
||||||
category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT',
|
safetySettings: [
|
||||||
threshold: 'BLOCK_NONE',
|
{ category: 'HARM_CATEGORY_HATE_SPEECH', threshold: 'BLOCK_NONE' },
|
||||||
},
|
{
|
||||||
{
|
category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT',
|
||||||
category: 'HARM_CATEGORY_DANGEROUS_CONTENT',
|
threshold: 'BLOCK_NONE',
|
||||||
threshold: 'BLOCK_NONE',
|
},
|
||||||
},
|
{
|
||||||
{ category: 'HARM_CATEGORY_HARASSMENT', threshold: 'BLOCK_NONE' },
|
category: 'HARM_CATEGORY_DANGEROUS_CONTENT',
|
||||||
],
|
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
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
Generated
+5763
-123
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
+162
-144
@@ -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 }
|
||||||
@@ -302,180 +320,180 @@ exports.playbackWebhook = onRequest(
|
|||||||
secrets: [HEYGEN_API_KEY, HEYGEN_WEBHOOK_TOKEN],
|
secrets: [HEYGEN_API_KEY, HEYGEN_WEBHOOK_TOKEN],
|
||||||
},
|
},
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
if (req.method !== 'POST') {
|
if (req.method !== 'POST') {
|
||||||
res.status(405).json({ ok: false, message: 'Method not allowed' })
|
res.status(405).json({ ok: false, message: 'Method not allowed' })
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const expectedToken = getSecretValue(HEYGEN_WEBHOOK_TOKEN, 'HEYGEN_WEBHOOK_TOKEN')
|
|
||||||
const token = trimString(req?.query?.token)
|
|
||||||
|
|
||||||
if (!token || token !== expectedToken) {
|
|
||||||
res.status(401).json({ ok: false, message: 'Unauthorized webhook token' })
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const { eventType, callbackId, videoId } = extractWebhookPayload(req.body || {})
|
try {
|
||||||
|
const expectedToken = getSecretValue(HEYGEN_WEBHOOK_TOKEN, 'HEYGEN_WEBHOOK_TOKEN')
|
||||||
|
const token = trimString(req?.query?.token)
|
||||||
|
|
||||||
if (!callbackId && !videoId) {
|
if (!token || token !== expectedToken) {
|
||||||
logger.warn('[HeyGen] webhook ignored: missing identifiers', {
|
res.status(401).json({ ok: false, message: 'Unauthorized webhook token' })
|
||||||
eventType,
|
return
|
||||||
bodyKeys: Object.keys(req.body || {}),
|
}
|
||||||
})
|
|
||||||
res.status(400).json({ ok: false, message: 'Missing callback_id or video_id' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const projectDoc = await findProjectForWebhook({ callbackId, videoId })
|
const { eventType, callbackId, videoId } = extractWebhookPayload(req.body || {})
|
||||||
if (!projectDoc) {
|
|
||||||
logger.info('[HeyGen] webhook ignored: project not found', {
|
|
||||||
callbackId,
|
|
||||||
videoId,
|
|
||||||
eventType,
|
|
||||||
})
|
|
||||||
res.status(202).json({ ok: true, ignored: true })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const project = projectDoc.data() || {}
|
if (!callbackId && !videoId) {
|
||||||
const currentCallbackId = trimString(project?.playbackCallbackId)
|
logger.warn('[HeyGen] webhook ignored: missing identifiers', {
|
||||||
const currentVideoId = trimString(project?.playbackJobId)
|
eventType,
|
||||||
|
bodyKeys: Object.keys(req.body || {}),
|
||||||
|
})
|
||||||
|
res.status(400).json({ ok: false, message: 'Missing callback_id or video_id' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (callbackId && currentCallbackId && callbackId !== currentCallbackId) {
|
const projectDoc = await findProjectForWebhook({ callbackId, videoId })
|
||||||
logger.info('[HeyGen] webhook ignored: stale callback_id', {
|
if (!projectDoc) {
|
||||||
projectId: projectDoc.id,
|
logger.info('[HeyGen] webhook ignored: project not found', {
|
||||||
callbackId,
|
callbackId,
|
||||||
currentCallbackId,
|
videoId,
|
||||||
})
|
eventType,
|
||||||
res.status(202).json({ ok: true, ignored: true })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (videoId && currentVideoId && videoId !== currentVideoId) {
|
|
||||||
logger.info('[HeyGen] webhook ignored: stale video_id', {
|
|
||||||
projectId: projectDoc.id,
|
|
||||||
videoId,
|
|
||||||
currentVideoId,
|
|
||||||
})
|
|
||||||
res.status(202).json({ ok: true, ignored: true })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const canonicalVideoId = currentVideoId || videoId
|
|
||||||
if (!canonicalVideoId) {
|
|
||||||
logger.warn('[HeyGen] webhook ignored: no canonical video id', {
|
|
||||||
projectId: projectDoc.id,
|
|
||||||
callbackId,
|
|
||||||
})
|
|
||||||
res.status(202).json({ ok: true, ignored: true })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const canonicalVideo = await fetchCanonicalVideo(canonicalVideoId)
|
|
||||||
const canonicalStatus = normalizeStatus(canonicalVideo?.status)
|
|
||||||
|
|
||||||
if (canonicalStatus === 'completed') {
|
|
||||||
const canonicalVideoUrl = trimString(canonicalVideo?.video_url)
|
|
||||||
if (!canonicalVideoUrl) {
|
|
||||||
logger.warn('[HeyGen] webhook ignored: completed without video_url', {
|
|
||||||
projectId: projectDoc.id,
|
|
||||||
canonicalVideoId,
|
|
||||||
})
|
})
|
||||||
res.status(202).json({ ok: true, ignored: true })
|
res.status(202).json({ ok: true, ignored: true })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const notificationRef = db
|
const project = projectDoc.data() || {}
|
||||||
.collection('notifications')
|
const currentCallbackId = trimString(project?.playbackCallbackId)
|
||||||
.doc(
|
const currentVideoId = trimString(project?.playbackJobId)
|
||||||
|
|
||||||
|
if (callbackId && currentCallbackId && callbackId !== currentCallbackId) {
|
||||||
|
logger.info('[HeyGen] webhook ignored: stale callback_id', {
|
||||||
|
projectId: projectDoc.id,
|
||||||
|
callbackId,
|
||||||
|
currentCallbackId,
|
||||||
|
})
|
||||||
|
res.status(202).json({ ok: true, ignored: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (videoId && currentVideoId && videoId !== currentVideoId) {
|
||||||
|
logger.info('[HeyGen] webhook ignored: stale video_id', {
|
||||||
|
projectId: projectDoc.id,
|
||||||
|
videoId,
|
||||||
|
currentVideoId,
|
||||||
|
})
|
||||||
|
res.status(202).json({ ok: true, ignored: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const canonicalVideoId = currentVideoId || videoId
|
||||||
|
if (!canonicalVideoId) {
|
||||||
|
logger.warn('[HeyGen] webhook ignored: no canonical video id', {
|
||||||
|
projectId: projectDoc.id,
|
||||||
|
callbackId,
|
||||||
|
})
|
||||||
|
res.status(202).json({ ok: true, ignored: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const canonicalVideo = await fetchCanonicalVideo(canonicalVideoId)
|
||||||
|
const canonicalStatus = normalizeStatus(canonicalVideo?.status)
|
||||||
|
|
||||||
|
if (canonicalStatus === 'completed') {
|
||||||
|
const canonicalVideoUrl = trimString(canonicalVideo?.video_url)
|
||||||
|
if (!canonicalVideoUrl) {
|
||||||
|
logger.warn('[HeyGen] webhook ignored: completed without video_url', {
|
||||||
|
projectId: projectDoc.id,
|
||||||
|
canonicalVideoId,
|
||||||
|
})
|
||||||
|
res.status(202).json({ ok: true, ignored: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const notificationRef = db
|
||||||
|
.collection('notifications')
|
||||||
|
.doc(
|
||||||
buildPlaybackReadyNotificationId({
|
buildPlaybackReadyNotificationId({
|
||||||
projectId: projectDoc.id,
|
projectId: projectDoc.id,
|
||||||
videoId: canonicalVideoId,
|
videoId: canonicalVideoId,
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
await db.runTransaction(async (transaction) => {
|
await db.runTransaction(async (transaction) => {
|
||||||
const [latestProjectSnap, notificationSnap] = await Promise.all([
|
const [latestProjectSnap, notificationSnap] = await Promise.all([
|
||||||
transaction.get(projectDoc.ref),
|
transaction.get(projectDoc.ref),
|
||||||
transaction.get(notificationRef),
|
transaction.get(notificationRef),
|
||||||
])
|
])
|
||||||
const latestProject = latestProjectSnap.data() || {}
|
const latestProject = latestProjectSnap.data() || {}
|
||||||
const userId = trimString(latestProject?.userId)
|
const userId = trimString(latestProject?.userId)
|
||||||
const projectTitle = trimString(latestProject?.title) || 'ton projet'
|
const projectTitle = trimString(latestProject?.title) || 'ton projet'
|
||||||
|
|
||||||
transaction.set(
|
transaction.set(
|
||||||
projectDoc.ref,
|
projectDoc.ref,
|
||||||
|
{
|
||||||
|
playbackProvider: 'heygen',
|
||||||
|
playbackStatus: PLAYBACK_STATUS.DRAFT_READY,
|
||||||
|
playbackGenerating: false,
|
||||||
|
playbackDraftUrl: canonicalVideoUrl,
|
||||||
|
playbackJobId: canonicalVideoId,
|
||||||
|
playbackCompletedAt: FieldValue.serverTimestamp(),
|
||||||
|
playbackError: null,
|
||||||
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
|
},
|
||||||
|
{ merge: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (userId && !notificationSnap.exists) {
|
||||||
|
transaction.set(notificationRef, {
|
||||||
|
sender: 'SYSTEM',
|
||||||
|
receiver: userId,
|
||||||
|
receiverCollection: 'users',
|
||||||
|
title: 'Ton playback IA est prêt !',
|
||||||
|
message: `La vidéo IA de "${projectTitle}" est terminée. Tu peux maintenant la découvrir et la valider.`,
|
||||||
|
time: FieldValue.serverTimestamp(),
|
||||||
|
read: false,
|
||||||
|
readAt: null,
|
||||||
|
mailOnly: false,
|
||||||
|
data: {
|
||||||
|
type: 'PLAYBACK_GENERATION_SUCCESS',
|
||||||
|
projectId: projectDoc.id,
|
||||||
|
projectTitle,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
res.status(200).json({ ok: true, status: PLAYBACK_STATUS.DRAFT_READY })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canonicalStatus === 'failed') {
|
||||||
|
const failureMessage =
|
||||||
|
trimString(canonicalVideo?.failure_message) || 'La génération HeyGen a échoué'
|
||||||
|
|
||||||
|
await projectDoc.ref.set(
|
||||||
{
|
{
|
||||||
playbackProvider: 'heygen',
|
playbackProvider: 'heygen',
|
||||||
playbackStatus: PLAYBACK_STATUS.DRAFT_READY,
|
playbackStatus: PLAYBACK_STATUS.FAILED,
|
||||||
playbackGenerating: false,
|
playbackGenerating: false,
|
||||||
playbackDraftUrl: canonicalVideoUrl,
|
playbackError: failureMessage,
|
||||||
playbackJobId: canonicalVideoId,
|
playbackJobId: canonicalVideoId,
|
||||||
playbackCompletedAt: FieldValue.serverTimestamp(),
|
|
||||||
playbackError: null,
|
|
||||||
updatedAt: FieldValue.serverTimestamp(),
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
},
|
},
|
||||||
{ merge: true }
|
{ merge: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
if (userId && !notificationSnap.exists) {
|
res.status(200).json({ ok: true, status: PLAYBACK_STATUS.FAILED })
|
||||||
transaction.set(notificationRef, {
|
return
|
||||||
sender: 'SYSTEM',
|
}
|
||||||
receiver: userId,
|
|
||||||
receiverCollection: 'users',
|
logger.info('[HeyGen] webhook acknowledged without terminal status', {
|
||||||
title: 'Ton playback IA est prêt !',
|
projectId: projectDoc.id,
|
||||||
message: `La vidéo IA de "${projectTitle}" est terminée. Tu peux maintenant la découvrir et la valider.`,
|
canonicalVideoId,
|
||||||
time: FieldValue.serverTimestamp(),
|
canonicalStatus,
|
||||||
read: false,
|
eventType,
|
||||||
readAt: null,
|
|
||||||
mailOnly: false,
|
|
||||||
data: {
|
|
||||||
type: 'PLAYBACK_GENERATION_SUCCESS',
|
|
||||||
projectId: projectDoc.id,
|
|
||||||
projectTitle,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
res.status(200).json({ ok: true, status: PLAYBACK_STATUS.DRAFT_READY })
|
res.status(202).json({ ok: true, status: canonicalStatus || 'processing' })
|
||||||
return
|
} catch (error) {
|
||||||
|
logger.error('[HeyGen] webhook failed', {
|
||||||
|
message: error?.message || String(error || ''),
|
||||||
|
})
|
||||||
|
res.status(500).json({ ok: false, message: error?.message || 'Webhook failure' })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (canonicalStatus === 'failed') {
|
|
||||||
const failureMessage =
|
|
||||||
trimString(canonicalVideo?.failure_message) || 'La génération HeyGen a échoué'
|
|
||||||
|
|
||||||
await projectDoc.ref.set(
|
|
||||||
{
|
|
||||||
playbackProvider: 'heygen',
|
|
||||||
playbackStatus: PLAYBACK_STATUS.FAILED,
|
|
||||||
playbackGenerating: false,
|
|
||||||
playbackError: failureMessage,
|
|
||||||
playbackJobId: canonicalVideoId,
|
|
||||||
updatedAt: FieldValue.serverTimestamp(),
|
|
||||||
},
|
|
||||||
{ merge: true }
|
|
||||||
)
|
|
||||||
|
|
||||||
res.status(200).json({ ok: true, status: PLAYBACK_STATUS.FAILED })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info('[HeyGen] webhook acknowledged without terminal status', {
|
|
||||||
projectId: projectDoc.id,
|
|
||||||
canonicalVideoId,
|
|
||||||
canonicalStatus,
|
|
||||||
eventType,
|
|
||||||
})
|
|
||||||
|
|
||||||
res.status(202).json({ ok: true, status: canonicalStatus || 'processing' })
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[HeyGen] webhook failed', {
|
|
||||||
message: error?.message || String(error || ''),
|
|
||||||
})
|
|
||||||
res.status(500).json({ ok: false, message: error?.message || 'Webhook failure' })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
+54
-12
@@ -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' },
|
||||||
|
|||||||
+146
-87
@@ -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(),
|
}
|
||||||
},
|
if (refundResult?.orderId) {
|
||||||
{ merge: true }
|
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 {
|
||||||
|
|||||||
@@ -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 })
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-14
@@ -36,21 +36,24 @@ 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
|
||||||
const data = doc.data() || {}
|
.filter((doc) => doc.data()?.songPublishedOnMusicLand !== false)
|
||||||
return {
|
.slice(0, 3)
|
||||||
rank: index + 1,
|
.map((doc, index) => {
|
||||||
projectId: doc.id,
|
const data = doc.data() || {}
|
||||||
title: data.title || null,
|
return {
|
||||||
userId: data.userId || null,
|
rank: index + 1,
|
||||||
userName: data.userName || null,
|
projectId: doc.id,
|
||||||
coverUrl: data.coverUrl || null,
|
title: data.title || null,
|
||||||
songUrl: data.songUrl || null,
|
userId: data.userId || null,
|
||||||
views: data.views || 0,
|
userName: data.userName || null,
|
||||||
}
|
coverUrl: data.coverUrl || null,
|
||||||
})
|
songUrl: data.songUrl || null,
|
||||||
|
views: data.views || 0,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const docRef = firestore.collection('monthlyTopSongs').doc(context.monthKey)
|
const docRef = firestore.collection('monthlyTopSongs').doc(context.monthKey)
|
||||||
|
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) l’ancienne 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 l’exé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
@@ -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
@@ -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) => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,22 +121,24 @@ const FullscreenIntroVideo = ({ url, visible = true, onClose }) => {
|
|||||||
// right: 24,
|
// right: 24,
|
||||||
// }}
|
// }}
|
||||||
/>
|
/>
|
||||||
<Pressable
|
{allowSkip ? (
|
||||||
onPress={handleClose}
|
<Pressable
|
||||||
style={{
|
onPress={handleClose}
|
||||||
position: 'absolute',
|
style={{
|
||||||
top: 50,
|
position: 'absolute',
|
||||||
right: 20,
|
top: 50,
|
||||||
backgroundColor: '#00000080',
|
right: 20,
|
||||||
paddingVertical: 10,
|
backgroundColor: '#00000080',
|
||||||
paddingHorizontal: 14,
|
paddingVertical: 10,
|
||||||
borderRadius: 20,
|
paddingHorizontal: 14,
|
||||||
borderWidth: 1,
|
borderRadius: 20,
|
||||||
borderColor: '#FFFFFF55',
|
borderWidth: 1,
|
||||||
}}
|
borderColor: '#FFFFFF55',
|
||||||
>
|
}}
|
||||||
<Text style={{ color: '#FFF', fontSize: 14 }}>Passer la vidéo</Text>
|
>
|
||||||
</Pressable>
|
<Text style={{ color: '#FFF', fontSize: 14 }}>{skipLabel}</Text>
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
</Portal>
|
</Portal>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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}>
|
||||||
</Pressable>
|
<Text style={closeTextStyle}>{playLabel}</Text>
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
{allowSkip ? (
|
||||||
|
<Pressable onPress={handleClose} style={closeButtonStyle}>
|
||||||
|
<Text style={closeTextStyle}>{skipLabel}</Text>
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
</Portal>
|
</Portal>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export const REGISTRATION_TERMS_VERSION = 'musicland-2023-10-22'
|
||||||
@@ -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,13 +171,22 @@ export function uploadFileToFirebase({
|
|||||||
reject(error)
|
reject(error)
|
||||||
},
|
},
|
||||||
async () => {
|
async () => {
|
||||||
const url = await firebase.storage().ref(path).getDownloadURL()
|
try {
|
||||||
|
const url = await firebase.storage().ref(path).getDownloadURL()
|
||||||
|
|
||||||
console.log('[uploadToFirebase] upload success (finalize)', { resultURI: url })
|
console.log('[uploadToFirebase] upload success (finalize)', { resultURI: url })
|
||||||
if (uploadBlob && typeof uploadBlob.close === 'function') {
|
if (uploadBlob && typeof uploadBlob.close === 'function') {
|
||||||
uploadBlob.close()
|
uploadBlob.close()
|
||||||
|
}
|
||||||
|
resolve({ resultURI: url })
|
||||||
|
} catch (error) {
|
||||||
|
console.log('[uploadToFirebase] download URL error', {
|
||||||
|
message: error?.message,
|
||||||
|
code: error?.code,
|
||||||
|
name: error?.name,
|
||||||
|
})
|
||||||
|
reject(error)
|
||||||
}
|
}
|
||||||
resolve({ resultURI: url })
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
stateRef.current = {
|
// this render. Consumers can therefore use the shared player as their only
|
||||||
isPlaying,
|
// playback clock without maintaining a second timer.
|
||||||
positionMs,
|
stateRef.current = {
|
||||||
durationMs,
|
isPlaying,
|
||||||
}
|
positionMs,
|
||||||
}, [isPlaying, positionMs, durationMs])
|
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 } = {}) => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
@@ -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 s’est 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,
|
||||||
|
|||||||
+29
-291
@@ -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,21 +221,23 @@ const CreatePassword = () => {
|
|||||||
fontFamily: FONT_FAMILY.InterRegular,
|
fontFamily: FONT_FAMILY.InterRegular,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
J'accepte les
|
{termsAccepted ? 'CGU et CGV acceptées' : 'Lire et accepter les'}
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
<Pressable onPress={handleOpenCguModal}>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontSize: 14,
|
|
||||||
color: Palette.white,
|
|
||||||
fontFamily: FONT_FAMILY.InterRegular,
|
|
||||||
textDecorationLine: 'underline',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
CGU
|
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
{!termsAccepted ? (
|
||||||
|
<Pressable onPress={handleOpenTerms}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
color: Palette.white,
|
||||||
|
fontFamily: FONT_FAMILY.InterRegular,
|
||||||
|
textDecorationLine: 'underline',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
CGU et CGV
|
||||||
|
</Text>
|
||||||
|
</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'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'en bas pour afficher les actions
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</Modal>
|
|
||||||
</Page>
|
</Page>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }}>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 t’inté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
@@ -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}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -414,4 +414,4 @@ const styles = StyleSheet.create({
|
|||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
marginBottom: 6,
|
marginBottom: 6,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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 = () => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 routeProject
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedProject?.id) {
|
||||||
return selectedProject
|
return selectedProject
|
||||||
}
|
}
|
||||||
return routeProject || null
|
|
||||||
|
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)
|
||||||
uri: selectedPhotoUri,
|
const path = `users/${currentUID}/projects/${project.id}/heygen/photo-${Date.now()}.${extension}`
|
||||||
path: `users/${currentUID}/projects/${project.id}/heygen/photo-${Date.now()}.${extension}`,
|
|
||||||
shouldCompress: true,
|
console.log('[PlaybackAi] photo upload start', {
|
||||||
fileType: 'IMAGE',
|
path,
|
||||||
|
fileSize: selectedPhotoAsset?.fileSize || null,
|
||||||
|
platform: Platform.OS,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const uploadResult = await withTimeout(
|
||||||
|
uploadFileToFirebase({
|
||||||
|
uri: selectedPhotoUri,
|
||||||
|
path,
|
||||||
|
shouldCompress: true,
|
||||||
|
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'
|
||||||
projectId: project.id,
|
console.log('[PlaybackAi] HeyGen request start', { projectId: project.id })
|
||||||
photoUrl,
|
await withTimeout(
|
||||||
})
|
callStartPlaybackGeneration({
|
||||||
|
projectId: project.id,
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
void stopAndNavigate()
|
||||||
progressTimerRef.current = setInterval(async () => {
|
}, [durationMs, isRecording, positionMs, stopAndNavigate])
|
||||||
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()
|
|
||||||
}
|
|
||||||
}, 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
|
||||||
@@ -761,7 +642,7 @@ const RecordPlayback = ({ route }) => {
|
|||||||
// containerStyle={{ backgroundColor: Palette.gray }}
|
// containerStyle={{ backgroundColor: Palette.gray }}
|
||||||
headerType="NONE"
|
headerType="NONE"
|
||||||
backgroundColor={Palette.grayMid}
|
backgroundColor={Palette.grayMid}
|
||||||
// backgroundImg={background.playbackBG2}
|
// backgroundImg={background.playbackBG2}
|
||||||
>
|
>
|
||||||
<MusicLandHeader progress={9} onPressBack={goBack} />
|
<MusicLandHeader progress={9} onPressBack={goBack} />
|
||||||
|
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -398,8 +402,8 @@ const RecordedPlayback = ({ route }) => {
|
|||||||
{currentSyncOffsetMs === 0
|
{currentSyncOffsetMs === 0
|
||||||
? 'ms · synchronisé'
|
? 'ms · synchronisé'
|
||||||
: currentSyncOffsetMs > 0
|
: currentSyncOffsetMs > 0
|
||||||
? 'ms · son en avance'
|
? 'ms · son en avance'
|
||||||
: 'ms · son en retard'}
|
: 'ms · son en retard'}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -433,8 +437,8 @@ const RecordedPlayback = ({ route }) => {
|
|||||||
!songUrl || currentSyncOffsetMs === 0
|
!songUrl || currentSyncOffsetMs === 0
|
||||||
? 'rgba(255,255,255,0.06)'
|
? 'rgba(255,255,255,0.06)'
|
||||||
: pressed
|
: pressed
|
||||||
? 'rgba(255,255,255,0.18)'
|
? 'rgba(255,255,255,0.18)'
|
||||||
: 'rgba(255,255,255,0.1)',
|
: 'rgba(255,255,255,0.1)',
|
||||||
opacity: !songUrl || currentSyncOffsetMs === 0 ? 0.4 : 1,
|
opacity: !songUrl || currentSyncOffsetMs === 0 ? 0.4 : 1,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -454,8 +455,8 @@ const RecordedPlayback = ({ route }) => {
|
|||||||
{currentSyncOffsetMs === 0
|
{currentSyncOffsetMs === 0
|
||||||
? 'ms · synchronisé'
|
? 'ms · synchronisé'
|
||||||
: currentSyncOffsetMs > 0
|
: currentSyncOffsetMs > 0
|
||||||
? 'ms · son en avance'
|
? 'ms · son en avance'
|
||||||
: 'ms · son en retard'}
|
: 'ms · son en retard'}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -489,8 +490,8 @@ const RecordedPlayback = ({ route }) => {
|
|||||||
!songUrl || currentSyncOffsetMs === 0
|
!songUrl || currentSyncOffsetMs === 0
|
||||||
? 'rgba(255,255,255,0.06)'
|
? 'rgba(255,255,255,0.06)'
|
||||||
: pressed
|
: pressed
|
||||||
? 'rgba(255,255,255,0.18)'
|
? 'rgba(255,255,255,0.18)'
|
||||||
: 'rgba(255,255,255,0.1)',
|
: 'rgba(255,255,255,0.1)',
|
||||||
opacity: !songUrl || currentSyncOffsetMs === 0 ? 0.4 : 1,
|
opacity: !songUrl || currentSyncOffsetMs === 0 ? 0.4 : 1,
|
||||||
cursor: !songUrl || currentSyncOffsetMs === 0 ? 'not-allowed' : 'pointer',
|
cursor: !songUrl || currentSyncOffsetMs === 0 ? 'not-allowed' : 'pointer',
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
return selectedProject
|
if (selectedProject?.id === routeProject.id) {
|
||||||
|
return selectedProject
|
||||||
|
}
|
||||||
|
return routeProject
|
||||||
}
|
}
|
||||||
return routeProject || selectedProject || null
|
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])
|
|
||||||
|
|
||||||
const handleDownloadUri = useCallback(async () => {
|
|
||||||
if (isPublishing || isDownloading) return
|
|
||||||
try {
|
|
||||||
setIsDownloading(true)
|
|
||||||
const resultURI = await processMerge()
|
|
||||||
setTooltip({
|
|
||||||
type: 'success',
|
|
||||||
text: 'Playback prêt à télécharger',
|
|
||||||
})
|
|
||||||
await triggerDownload(resultURI, projectForDownload?.title)
|
|
||||||
} catch (error) {
|
|
||||||
setTooltip({
|
|
||||||
type: 'error',
|
|
||||||
text: String(error?.message || 'Erreur lors de la publication du playback'),
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
setIsDownloading(false)
|
|
||||||
}
|
|
||||||
}, [
|
}, [
|
||||||
isDownloading,
|
action,
|
||||||
isPublishing,
|
currentUID,
|
||||||
processMerge,
|
pendingPlaybackUrl,
|
||||||
projectForDownload,
|
projectForDownload,
|
||||||
setTooltip,
|
resolveAudioUrl,
|
||||||
|
setIsLoading,
|
||||||
|
syncOffsetMs,
|
||||||
|
uri,
|
||||||
])
|
])
|
||||||
|
|
||||||
|
const executePlaybackChoice = useCallback(
|
||||||
|
async (intent) => {
|
||||||
|
if (intent !== 'personal' && intent !== 'publish') return
|
||||||
|
if (isPublishing || isDownloading || !projectForDownload?.id) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsDownloading(true)
|
||||||
|
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({
|
||||||
|
type: 'success',
|
||||||
|
text:
|
||||||
|
intent === 'publish'
|
||||||
|
? 'Playback téléchargé et publié sur MusicLand !'
|
||||||
|
: 'Playback téléchargé à des fins personnelles.',
|
||||||
|
})
|
||||||
|
if (intent === 'publish') {
|
||||||
|
navigate(Routes.Home)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setTooltip({
|
||||||
|
type: 'error',
|
||||||
|
text: String(error?.message || "Impossible de terminer l'opération."),
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setIsDownloading(false)
|
||||||
|
setIsPublishing(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isDownloading, 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 {
|
return
|
||||||
setIsDownloading(true)
|
}
|
||||||
const url = await processMerge()
|
startPlaybackDownloadCheckout(intent)
|
||||||
await triggerDownload(url, projectForDownload?.title)
|
},
|
||||||
} catch (err) {
|
[
|
||||||
setTooltip({ type: 'error', text: err.message })
|
canDownloadPlayback,
|
||||||
} finally {
|
executePlaybackChoice,
|
||||||
setIsDownloading(false)
|
isCheckoutLaunching,
|
||||||
}
|
isDownloading,
|
||||||
}
|
isPublishing,
|
||||||
|
startPlaybackDownloadCheckout,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!downloadPaymentPending || !hasPaidPlaybackDownload || isDownloading) {
|
if (
|
||||||
|
!downloadPaymentPending ||
|
||||||
|
!hasPaidPlaybackDownload ||
|
||||||
|
!pendingDownloadIntent ||
|
||||||
|
isDownloading
|
||||||
|
) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const intent = pendingDownloadIntent
|
||||||
setDownloadPaymentPending(false)
|
setDownloadPaymentPending(false)
|
||||||
handleDownloadUri()
|
setPendingDownloadIntent(null)
|
||||||
}, [downloadPaymentPending, handleDownloadUri, hasPaidPlaybackDownload, isDownloading])
|
executePlaybackChoice(intent)
|
||||||
|
}, [
|
||||||
const handlePublish = async () => {
|
downloadPaymentPending,
|
||||||
if (!hasAcceptedPublication) return
|
executePlaybackChoice,
|
||||||
|
hasPaidPlaybackDownload,
|
||||||
if (isPublishing) return
|
isDownloading,
|
||||||
|
pendingDownloadIntent,
|
||||||
try {
|
])
|
||||||
setIsPublishing(true)
|
|
||||||
const url = await processMerge()
|
|
||||||
await projectsRef.doc(projectForDownload.id).set(
|
|
||||||
{
|
|
||||||
playbackUrl: url,
|
|
||||||
updatedAt: serverTimestamp(),
|
|
||||||
},
|
|
||||||
{ merge: true }
|
|
||||||
)
|
|
||||||
|
|
||||||
navigate(Routes.PublishYoutube, { projectId: projectForDownload.id })
|
|
||||||
} catch (err) {
|
|
||||||
setTooltip({
|
|
||||||
type: 'error',
|
|
||||||
text: String(err?.message || 'Erreur de publication'),
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
setIsPublishing(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<FullscreenIntroVideo
|
<FullscreenIntroVideo
|
||||||
@@ -425,75 +452,59 @@ 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"
|
||||||
<ClubAdvantagesCard
|
subTitle="Choisis l'utilisation de ta vidéo."
|
||||||
style={styles.clubCardSpacing}
|
|
||||||
topContent={
|
|
||||||
<View style={styles.coverRow}>
|
|
||||||
{projectForDownload?.coverUrl ? (
|
|
||||||
<ExpoImage
|
|
||||||
source={{ uri: projectForDownload?.coverUrl }}
|
|
||||||
style={styles.coverImage}
|
|
||||||
contentFit="cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<View style={[styles.coverImage, styles.coverPlaceholder]}>
|
|
||||||
<Text style={styles.placeholderText}>Aucune pochette</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<View style={styles.downloadColumn}>
|
|
||||||
<Pressable
|
|
||||||
style={[
|
|
||||||
styles.downloadTile,
|
|
||||||
(isPublishing || isDownloading || isCheckoutLaunching) && { opacity: 0.6 },
|
|
||||||
]}
|
|
||||||
onPress={handleDownloadPress}
|
|
||||||
disabled={isPublishing || isDownloading || isCheckoutLaunching}
|
|
||||||
>
|
|
||||||
<MaterialCommunityIcons name="download" size={22} color={Palette.white} />
|
|
||||||
<Text style={styles.downloadText}>{downloadLabel}</Text>
|
|
||||||
</Pressable>
|
|
||||||
{!canDownloadPlayback && downloadPaymentPending ? (
|
|
||||||
<Text style={styles.downloadPendingText}>
|
|
||||||
Paiement en attente de confirmation...
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<View style={styles.publishConsentContainer}>
|
<View style={styles.summaryCard}>
|
||||||
<AppCheckbox
|
{projectForDownload?.coverUrl ? (
|
||||||
selected={hasAcceptedPublication}
|
<ExpoImage
|
||||||
onPress={() => setHasAcceptedPublication((prevState) => !prevState)}
|
source={{ uri: projectForDownload.coverUrl }}
|
||||||
label="J'accepte la diffusion de mon playback sur Musicland."
|
style={styles.coverImage}
|
||||||
/>
|
contentFit="cover"
|
||||||
<Text style={styles.publishConsentDescription}>
|
/>
|
||||||
Cette confirmation est requise avant de lancer la publication.
|
) : (
|
||||||
</Text>
|
<View style={[styles.coverImage, styles.coverPlaceholder]}>
|
||||||
|
<Text style={styles.placeholderText}>Aucune pochette</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<Text style={styles.playbackTitle}>{projectForDownload?.title || 'Mon playback'}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<BorderGradientButton
|
<View style={styles.choiceSection}>
|
||||||
title={'Publier'}
|
<Text style={styles.sectionTitle}>Téléchargement</Text>
|
||||||
onPress={() => {
|
<Text style={styles.sectionDescription}>{downloadAccessLabel}</Text>
|
||||||
handlePublish()
|
<BorderGradientButton
|
||||||
}}
|
title="Télécharger à des fins personnelles"
|
||||||
disabled={isPublishing || !hasAcceptedPublication}
|
onPress={() => handlePlaybackChoice('personal')}
|
||||||
loading={isPublishing}
|
disabled={isPublishing || isDownloading || isCheckoutLaunching}
|
||||||
loadingText="Publication en cours..."
|
loading={isDownloading && !isPublishing}
|
||||||
containerStyle={styles.continueButton}
|
loadingText="Téléchargement en cours..."
|
||||||
/>
|
height={58}
|
||||||
|
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 ? (
|
||||||
|
<Text style={styles.downloadPendingText}>Paiement en attente de confirmation...</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
</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,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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={{
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
+142
-14
@@ -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}>
|
||||||
<Text style={styles.terms}>
|
<Pressable
|
||||||
En t’inscrivant, tu acceptes nos conditions générales{'\n'}
|
accessibilityRole="button"
|
||||||
d’utilisation et notre politique de confidentialité.
|
onPress={openRegistrationTerms}
|
||||||
|
style={styles.termsAcceptance}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={[styles.termsIndicator, termsAccepted ? styles.termsIndicatorAccepted : null]}
|
||||||
|
>
|
||||||
|
{termsAccepted ? <View style={styles.termsIndicatorDot} /> : null}
|
||||||
|
</View>
|
||||||
|
<Text style={styles.terms}>
|
||||||
|
{termsAccepted ? 'CGU et CGV acceptées' : 'Lire et accepter les CGU et CGV'}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Text style={styles.privacyText}>
|
||||||
|
En t’inscrivant, 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: {
|
||||||
|
|||||||
@@ -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'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 jusqu’en bas pour continuer.'}
|
||||||
|
</Text>
|
||||||
|
<GradientButton
|
||||||
|
title="J’accepte 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
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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%',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
+246
-263
@@ -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) {
|
||||||
await triggerWebDownload(downloadUrl, projectForStage?.title)
|
try {
|
||||||
await setLoading(false)
|
await triggerWebDownload(downloadUrl, projectForStage?.title)
|
||||||
return
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
setTooltip({
|
||||||
|
type: 'error',
|
||||||
|
text: error?.message || 'Impossible de télécharger le morceau.',
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
await setLoading(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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,170 +453,164 @@ 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]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleDownloadChoice = useCallback(
|
||||||
|
(intent) => {
|
||||||
|
if (isDownloading || isCheckoutLaunching || isPublishing) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (canDownloadDirectly) {
|
||||||
|
executeDownloadChoice(intent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (downloadPaymentPending) {
|
||||||
|
Alert.alert(
|
||||||
|
'Paiement en attente',
|
||||||
|
"Le paiement n'est pas encore confirmé. Si tu as déjà payé, patiente quelques instants.",
|
||||||
|
[
|
||||||
|
{ text: 'Attendre', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Relancer le paiement',
|
||||||
|
onPress: () => startSongDownloadCheckout(intent, { force: true }),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (promptPurchaseConfirm) {
|
||||||
|
Alert.alert(
|
||||||
|
'Télécharger ce morceau',
|
||||||
|
'Voulez-vous télécharger ce morceau pour 1,99€ ?',
|
||||||
|
[
|
||||||
|
{ text: 'Annuler', style: 'cancel' },
|
||||||
|
{ text: 'Acheter', onPress: () => startSongDownloadCheckout(intent) },
|
||||||
|
],
|
||||||
|
{ cancelable: true }
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
startSongDownloadCheckout(intent)
|
||||||
|
},
|
||||||
[
|
[
|
||||||
createSongDownloadCheckout,
|
canDownloadDirectly,
|
||||||
downloadPaymentPending,
|
downloadPaymentPending,
|
||||||
|
executeDownloadChoice,
|
||||||
isCheckoutLaunching,
|
isCheckoutLaunching,
|
||||||
projectId,
|
isDownloading,
|
||||||
setTooltip,
|
isPublishing,
|
||||||
|
promptPurchaseConfirm,
|
||||||
|
startSongDownloadCheckout,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleDownloadPress = useCallback(() => {
|
|
||||||
if (isDownloading || isCheckoutLaunching) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (canDownloadDirectly) {
|
|
||||||
handleDownload()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (downloadPaymentPending) {
|
|
||||||
Alert.alert(
|
|
||||||
'Paiement en attente',
|
|
||||||
"Le paiement n'est pas encore confirmé. Si tu as déjà payé, patiente quelques instants.",
|
|
||||||
[
|
|
||||||
{ text: 'Attendre', style: 'cancel' },
|
|
||||||
{ text: 'Relancer le paiement', onPress: () => startSongDownloadCheckout({ force: true }) },
|
|
||||||
]
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (promptPurchaseConfirm) {
|
|
||||||
Alert.alert(
|
|
||||||
'Télécharger ce morceau',
|
|
||||||
'Voulez-vous télécharger ce morceau pour 1,99€ ?',
|
|
||||||
[
|
|
||||||
{ text: 'Annuler', style: 'cancel' },
|
|
||||||
{ text: 'Acheter', onPress: () => startSongDownloadCheckout() },
|
|
||||||
],
|
|
||||||
{ cancelable: true }
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
startSongDownloadCheckout()
|
|
||||||
}, [
|
|
||||||
canDownloadDirectly,
|
|
||||||
downloadPaymentPending,
|
|
||||||
handleDownload,
|
|
||||||
isCheckoutLaunching,
|
|
||||||
isDownloading,
|
|
||||||
promptPurchaseConfirm,
|
|
||||||
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,
|
!isWeb && styles.containerMobile,
|
||||||
!isWeb && styles.containerMobile,
|
styles.scrollContent,
|
||||||
styles.scrollContent,
|
]}
|
||||||
]}
|
showsVerticalScrollIndicator={false}
|
||||||
showsVerticalScrollIndicator={false}
|
>
|
||||||
>
|
<CreateLyricsHeader
|
||||||
<CreateLyricsHeader title="Ta pochette est validée" />
|
title="Ton morceau est prêt"
|
||||||
<ClubAdvantagesCard
|
subTitle="Choisis ce que tu souhaites faire maintenant."
|
||||||
style={styles.clubCardSpacing}
|
/>
|
||||||
topContent={
|
|
||||||
<View style={styles.coverRow}>
|
|
||||||
{coverUrl ? (
|
|
||||||
<ExpoImage source={{ uri: coverUrl }} style={styles.coverImage} contentFit="cover" />
|
|
||||||
) : (
|
|
||||||
<View style={[styles.coverImage, styles.coverPlaceholder]}>
|
|
||||||
<Text style={styles.placeholderText}>Aucune pochette</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<View style={styles.downloadColumn}>
|
<View style={styles.summaryCard}>
|
||||||
<Pressable
|
{coverUrl ? (
|
||||||
style={[
|
<ExpoImage source={{ uri: coverUrl }} style={styles.coverImage} contentFit="cover" />
|
||||||
styles.downloadTile,
|
) : (
|
||||||
(isDownloading || isCheckoutLaunching) && { opacity: 0.6 },
|
<View style={[styles.coverImage, styles.coverPlaceholder]}>
|
||||||
]}
|
<Text style={styles.placeholderText}>Aucune pochette</Text>
|
||||||
onPress={handleDownloadPress}
|
</View>
|
||||||
disabled={isDownloading || isCheckoutLaunching}
|
)}
|
||||||
>
|
<Text style={styles.trackTitle}>{trackTitle}</Text>
|
||||||
<MaterialCommunityIcons name="download" size={22} color={Palette.white} />
|
</View>
|
||||||
<Text style={styles.downloadText}>{downloadLabel}</Text>
|
|
||||||
</Pressable>
|
<View style={styles.choiceSection}>
|
||||||
{!canDownloadDirectly && downloadPaymentPending ? (
|
<Text style={styles.sectionTitle}>Téléchargement</Text>
|
||||||
<Text style={styles.downloadPendingText}>
|
<Text style={styles.sectionDescription}>{downloadAccessLabel}</Text>
|
||||||
Paiement en attente de confirmation...
|
<BorderGradientButton
|
||||||
</Text>
|
title="Option 1 · Téléchargement à des fins personnelles"
|
||||||
) : null}
|
onPress={() => handleDownloadChoice('personal')}
|
||||||
</View>
|
disabled={isDownloading || isCheckoutLaunching || isPublishing}
|
||||||
</View>
|
loading={isDownloading && !isPublishing}
|
||||||
}
|
loadingText="Téléchargement en cours..."
|
||||||
|
height={58}
|
||||||
|
titleStyle={styles.choiceButtonText}
|
||||||
/>
|
/>
|
||||||
<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}
|
||||||
/>
|
/>
|
||||||
</ScrollView>
|
{!canDownloadDirectly && downloadPaymentPending ? (
|
||||||
) : (
|
<Text style={styles.downloadPendingText}>Paiement en attente de confirmation...</Text>
|
||||||
<View style={styles.adventureGatePlaceholder} />
|
) : 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>
|
||||||
<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
@@ -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 {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 l’onglet « Identifiants », repère l’ID client OAuth 2.0 existant au format :
|
|
||||||
```
|
|
||||||
123456789012-abcdefghijklmnopqrstu.apps.googleusercontent.com
|
|
||||||
```
|
|
||||||
Copie-le : c’est `YOUR_CLIENT_ID`.
|
|
||||||
4. S’il n’existe 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 l’ID 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` n’est renvoyé qu’une 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"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
Reference in New Issue
Block a user