feat: refine creation and registration flows

This commit is contained in:
Sacha
2026-07-30 09:42:12 +02:00
parent 3fff09ba81
commit 761f3cc200
39 changed files with 1444 additions and 1239 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 864 KiB

+46
View File
@@ -0,0 +1,46 @@
# Design QA — Pochette MusicLand Playbacker
- Source visual truth: `/Users/sacha/Library/Containers/net.whatsapp.WhatsApp/Data/tmp/documents/AC89FF71-250F-4949-9D82-F89EE91CF735/PHOTO-2026-07-28-13-57-44.jpg`
- Supplied base asset: `/Users/sacha/Projets/MusicLand/src/assets/UI/musiclandPlaybackCover.png`
- Implementation screenshot: `/Users/sacha/Projets/MusicLand/design-qa-assets/musicland-cover-preview.png`
- Side-by-side comparison: `/Users/sacha/Projets/MusicLand/design-qa-assets/musicland-cover-comparison.png`
- State: deterministic cover with a profile picture, artist name `username`, and `PLAYBACKER`
- Viewport / output: square cover at 1024 × 1024 px
- Density normalization: the 894 × 890 px source photo was cropped to its 661 × 661 px cover region, then normalized to 1024 × 1024 px. The implementation output is native 1024 × 1024 px at density 1.
## Full-view comparison evidence
The comparison places the normalized source on the left and the deterministic implementation on the right. The MusicLand artwork, central profile-photo placement, artist-name hierarchy, `PLAYBACKER` label, rounded corners, palette, and square composition align with the supplied target.
## Focused-region comparison evidence
The full cover is the focused component for this request, so a separate crop was not needed. The critical regions remain clearly readable at 1024 px:
- top MusicLand brand;
- central circular profile picture inside the supplied ring;
- artist name below the profile picture;
- `PLAYBACKER` label at the bottom.
## Required fidelity surfaces
- Fonts and typography: white, light sans-serif text matches the reference hierarchy. Long artist names use a smaller server-side size and the app preview scales the text to one line.
- Spacing and layout rhythm: the profile, artist name, and bottom label follow the same vertical order and approximate positions as the source.
- Colors and visual tokens: the supplied blue-purple-pink artwork is reused directly without recoloring or recreated gradients.
- Image quality and asset fidelity: the supplied PNG is used as the source asset. The final server output is rendered at 1024 × 1024 px. The profile image is center-cropped and circularly masked.
- Copy and content: `MUSICLAND`, the selected artist name, and `PLAYBACKER` are the only cover content.
## Findings
No actionable P0, P1, or P2 visual mismatch remains.
## Comparison history
1. Initial output placed the artist name and `PLAYBACKER` too high and too large.
2. Font sizes and vertical positions were adjusted.
3. The revised side-by-side comparison shows the intended hierarchy and spacing.
## Residual test gaps
The in-app browser blocked local `localhost` navigation during QA, so the surrounding step-2 screen could not be captured there. The web bundle completed successfully, and the actual deterministic cover renderer was executed locally to produce the implementation evidence above.
final result: passed
Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

+189 -261
View File
@@ -5,261 +5,201 @@ const logger = require('firebase-functions/logger')
const axios = require('axios') const axios = require('axios')
const sharp = require('sharp') const sharp = require('sharp')
const crypto = require('crypto') const crypto = require('crypto')
const path = require('path')
// Imports internes
const { GEMINI_API_KEY } = require('../config/secrets')
const { generateImageV2 } = require('../helpers/gemini')
const { generatePicturePrompt } = require('../helpers/prompts')
const { ALERT_TYPE, refList } = require('../index') const { ALERT_TYPE, refList } = require('../index')
const { sendNotification } = require('./notifications') const { sendNotification } = require('./notifications')
// Configuration
const bucket = admin.storage().bucket() const bucket = admin.storage().bucket()
const BRAND_TEXT = 'By Musicland.ai' const COVER_TEMPLATE = 'MUSICLAND_PLAYBACKER'
const BRAND_FONT_FAMILY = 'Poppins, Montserrat, Arial, sans-serif' const COVER_OPTION_ID = 'musicland-playbacker'
const BRAND_BG_COLOR = 'rgba(0,0,0,0.65)' const COVER_SIZE = 1024
const PROFILE_SIZE = 228
const TEMPLATE_PATH = path.join(__dirname, '../assets/musiclandPlaybackCover.png')
const FONT_FAMILY = 'Poppins, Montserrat, Arial, sans-serif'
const escapeSvgText = (value = '') => const escapeSvgText = (value = '') =>
String(value).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;') String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
const buildBrandSvg = ({ width = 1024, height = 1024, text = BRAND_TEXT } = {}) => { const normalizeArtistName = (value) => {
const safeWidth = Math.max(320, Math.round(width)) if (typeof value !== 'string') {
const safeHeight = Math.max(320, Math.round(height)) return 'MusicLand'
const margin = Math.round(safeWidth * 0.04) }
const fontSize = Math.max(22, Math.round(safeWidth * 0.045)) const trimmedValue = value.trim()
const letterSpacing = Math.max(1, Math.round(fontSize * 0.06)) return trimmedValue || 'MusicLand'
const strokeWidth = Math.max(2, Math.round(fontSize * 0.08)) }
const shadowDy = Math.max(2, Math.round(fontSize * 0.08))
const shadowBlur = Math.max(4, Math.round(fontSize * 0.18)) const getArtistFontSize = (artistName) => {
const paddingX = Math.max(10, Math.round(fontSize * 0.7)) if (artistName.length <= 16) return 52
const paddingY = Math.max(6, Math.round(fontSize * 0.4)) if (artistName.length <= 24) return 44
const approxCharWidth = fontSize * 0.62 return 36
const safeText = escapeSvgText(text) }
const textLength = safeText.length
const textWidth = const buildTextOverlay = (artistName) => {
approxCharWidth * textLength + letterSpacing * Math.max(0, textLength - 1) const safeArtistName = escapeSvgText(artistName)
const rectWidth = Math.max(0, Math.round(textWidth + paddingX * 2)) const artistFontSize = getArtistFontSize(artistName)
const rectHeight = Math.round(fontSize + paddingY * 2)
const rectX = Math.max(0, Math.round(safeWidth - margin - rectWidth))
const rectY = Math.max(0, Math.round(safeHeight - margin - rectHeight))
const textX = rectX + rectWidth / 2
const baselineShift = Math.round(fontSize * 0.3)
const textY = rectY + rectHeight / 2 + baselineShift
const rectRadius = Math.max(6, Math.round(rectHeight * 0.35))
return Buffer.from(` return Buffer.from(`
<svg width="${safeWidth}" height="${safeHeight}" viewBox="0 0 ${safeWidth} ${safeHeight}" xmlns="http://www.w3.org/2000/svg"> <svg width="${COVER_SIZE}" height="${COVER_SIZE}" viewBox="0 0 ${COVER_SIZE} ${COVER_SIZE}" xmlns="http://www.w3.org/2000/svg">
<defs>
<filter id="brandShadow" x="-50%" y="-50%" width="200%" height="200%">
<feDropShadow dx="0" dy="${shadowDy}" stdDeviation="${shadowBlur}" flood-color="rgba(0,0,0,0.55)" />
</filter>
</defs>
<rect
x="${rectX}"
y="${rectY}"
width="${rectWidth}"
height="${rectHeight}"
rx="${rectRadius}"
ry="${rectRadius}"
fill="${BRAND_BG_COLOR}"
/>
<text <text
x="${textX}" x="512"
y="${textY}" y="824"
text-anchor="middle" text-anchor="middle"
font-family="${BRAND_FONT_FAMILY}" dominant-baseline="middle"
font-size="${fontSize}" font-family="${FONT_FAMILY}"
font-style="italic" font-size="${artistFontSize}"
font-weight="700" font-weight="400"
letter-spacing="${letterSpacing}" letter-spacing="3"
fill="rgba(255,255,255,0.95)" fill="#FFFFFF"
stroke="rgba(0,0,0,0.45)" >${safeArtistName}</text>
stroke-width="${strokeWidth}" <text
paint-order="stroke" x="512"
filter="url(#brandShadow)" y="963"
>${safeText}</text> text-anchor="middle"
dominant-baseline="middle"
font-family="${FONT_FAMILY}"
font-size="54"
font-weight="400"
letter-spacing="5"
fill="#FFFFFF"
>PLAYBACKER</text>
</svg>`) </svg>`)
} }
/** const buildCircularMask = (size) => {
* Utilitaires Strings const mask = Buffer.alloc(size * size * 4)
*/ const radius = size / 2
const pickFirstNonEmpty = (...values) => { const center = radius - 0.5
for (const value of values) {
if (typeof value === 'string' && value.trim().length > 0) { for (let y = 0; y < size; y += 1) {
return value.trim() for (let x = 0; x < size; x += 1) {
const offset = (y * size + x) * 4
const distance = Math.sqrt((x - center) ** 2 + (y - center) ** 2)
const alpha = distance <= radius ? 255 : 0
mask[offset] = 255
mask[offset + 1] = 255
mask[offset + 2] = 255
mask[offset + 3] = alpha
} }
} }
return ''
return mask
} }
const combineNames = (...parts) => async function loadCoverIdentity(project) {
parts const artistName = normalizeArtistName(project.userName)
.map((part) => (typeof part === 'string' ? part.trim() : '')) const userId = typeof project.userId === 'string' ? project.userId.trim() : ''
.filter(Boolean)
.join(' ')
.trim()
/** if (!userId) {
* Résolution intelligente du nom d'artiste return { artistName, profilePictureURL: null }
*/ }
async function resolveArtistName(project = {}) {
// 1. Vérification directe sur le projet ou le snapshot "owner"
const owner = project?.owner || {}
const direct = pickFirstNonEmpty(
project?.artistName,
project?.userName,
owner?.artistName,
owner?.userName,
owner?.displayName
)
if (direct) return direct
// 2. Fallback : Récupération depuis la collection Users
const userId = typeof project?.userId === 'string' ? project.userId.trim() : ''
if (!userId) return ''
try { try {
const userSnapshot = await refList.users.doc(userId).get() const userSnapshot = await refList.users.doc(userId).get()
if (!userSnapshot?.exists) return '' const userData = userSnapshot.exists ? userSnapshot.data() : null
return {
const userData = userSnapshot.data() || {} artistName: normalizeArtistName(project.userName || userData?.userName),
return ( profilePictureURL: userData?.profilePictureURL || null,
pickFirstNonEmpty( }
userData.artistName,
userData.userName,
userData.displayName,
combineNames(userData.firstName, userData.lastName)
) || ''
)
} catch (error) { } catch (error) {
logger.warn('⚠️ [Cover] Artist name resolution failed', { logger.warn('[Cover] Unable to load profile picture', {
projectId: project?.id, projectId: project.id,
error: error.message, error: error.message,
}) })
return '' return { artistName, profilePictureURL: null }
} }
} }
/** async function buildProfileOverlay(profilePictureURL) {
* Ajoute le texte de marque en filigrane sur l'image générée if (!profilePictureURL) {
*/ return null
async function buildCoverWithBrandText(backgroundUrl, targetPath) { }
logger.info('🖼️ [Cover] Compositing brand text...')
try { try {
// Téléchargement background const response = await axios.get(profilePictureURL, { responseType: 'arraybuffer' })
const bgResponse = await axios.get(backgroundUrl, { responseType: 'arraybuffer' }) const circularMask = buildCircularMask(PROFILE_SIZE)
return await sharp(response.data)
const baseImage = sharp(bgResponse.data) .resize(PROFILE_SIZE, PROFILE_SIZE, { fit: 'cover', position: 'centre' })
const metadata = await baseImage.metadata()
const width = metadata.width || 1024
const height = metadata.height || 1024
const brandOverlay = buildBrandSvg({ width, height, text: BRAND_TEXT })
// Composition
const stampedBuffer = await baseImage
.ensureAlpha() .ensureAlpha()
.composite([{ input: brandOverlay, left: 0, top: 0, blend: 'over' }]) .composite([
{
input: circularMask,
raw: {
width: PROFILE_SIZE,
height: PROFILE_SIZE,
channels: 4,
},
blend: 'dest-in',
},
])
.png() .png()
.toBuffer() .toBuffer()
// Upload vers Storage
const token = crypto.randomUUID()
const file = bucket.file(targetPath)
await file.save(stampedBuffer, {
resumable: false,
metadata: {
contentType: 'image/png',
cacheControl: 'public, max-age=31536000',
metadata: { firebaseStorageDownloadTokens: token },
},
})
return `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(targetPath)}?alt=media&token=${token}`
} catch (error) { } catch (error) {
logger.error('[Cover] buildCoverWithBrandText failed', error) logger.warn('[Cover] Unable to load profile image, keeping default icon', {
// En cas d'échec du texte, on renvoie l'URL originale pour ne pas tout perdre error: error.message,
return backgroundUrl })
return null
} }
} }
/** async function uploadCover(buffer, targetPath) {
* Cœur de la logique de génération const token = crypto.randomUUID()
*/ const file = bucket.file(targetPath)
async function performCoverGeneration(project) {
const t0 = Date.now()
const artistName = await resolveArtistName(project)
const baseTimestamp = Date.now() await file.save(buffer, {
const GENERATION_COUNT = 2 // Nombre de variantes simultanées resumable: false,
metadata: {
// Création d'un tableau de promesses pour exécuter les tâches en parallèle contentType: 'image/png',
const generationPromises = Array.from({ length: GENERATION_COUNT }).map(async (_, index) => { cacheControl: 'public, max-age=31536000',
const uniqueSuffix = `${baseTimestamp}-${index}` metadata: { firebaseStorageDownloadTokens: token },
const storageBasePath = `users/${project.userId}/projects/${project.id}` },
const generatedPath = `${storageBasePath}/generated-${uniqueSuffix}.png`
const stampedPath = `${storageBasePath}/cover-${uniqueSuffix}-stamped.png`
const prompt = generatePicturePrompt({
...project,
artistName,
variantSeed: `${project.id || 'project'}-${uniqueSuffix}`,
})
logger.info('🎨 [Cover] Prompt generated', {
projectId: project.id,
artistName,
variant: uniqueSuffix,
promptPreview: prompt.slice(0, 100) + '...',
})
try {
// 1. Appel IA (Imagen 3) - S'exécute en parallèle des autres
const generatedUrl = await generateImageV2(prompt, 1024, generatedPath)
if (!generatedUrl) throw new Error("URL vide retournée par l'IA")
// 2. Ajout du Logo
const finalCoverUrl = await buildCoverWithBrandText(generatedUrl, stampedPath)
logger.info(`✅ [Cover] Option ${index + 1}/${GENERATION_COUNT} ready`)
return {
id: uniqueSuffix,
generatedUrl,
finalUrl: finalCoverUrl,
promptUsed: prompt,
}
} catch (e) {
// On catch l'erreur ICI pour ne pas faire échouer tout le Promise.all
logger.error(`❌ [Cover] Option ${index + 1} failed`, {
error: e.message,
})
return null // On retourne null pour filtrer plus tard
}
}) })
// Attente de la résolution de toutes les générations return `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(targetPath)}?alt=media&token=${token}`
const results = await Promise.all(generationPromises) }
// On garde uniquement les tentatives réussies (non null) async function prepareMusiclandCover(project) {
const options = results.filter(Boolean) const startedAt = Date.now()
const userId = typeof project.userId === 'string' ? project.userId.trim() : ''
if (options.length === 0) { if (!userId) {
throw new Error('Toutes les tentatives de génération ont échoué.') throw new Error('Utilisateur du projet introuvable')
} }
// Sauvegarde dans Firestore const { artistName, profilePictureURL } = await loadCoverIdentity(project)
const [firstOption] = options const profileOverlay = await buildProfileOverlay(profilePictureURL)
const overlays = [{ input: buildTextOverlay(artistName), left: 0, top: 0 }]
if (profileOverlay) {
overlays.unshift({ input: profileOverlay, left: 398, top: 454 })
}
const coverBuffer = await sharp(TEMPLATE_PATH)
.resize(COVER_SIZE, COVER_SIZE, { fit: 'cover', kernel: sharp.kernel.lanczos3 })
.ensureAlpha()
.composite(overlays)
.png()
.toBuffer()
const storagePath = `users/${userId}/projects/${project.id}/cover-musicland-playbacker.png`
const coverUrl = await uploadCover(coverBuffer, storagePath)
const option = {
id: COVER_OPTION_ID,
finalUrl: coverUrl,
generatedUrl: null,
}
await refList.projects.doc(project.id).set( await refList.projects.doc(project.id).set(
{ {
cover: { cover: {
generatedBackground: firstOption.generatedUrl, template: COVER_TEMPLATE,
result: firstOption.finalUrl, result: coverUrl,
// selectedOptionId: firstOption.id, // disable default selection generatedBackground: null,
options, // Sauvegarde de toutes les variantes réussies selectedOptionId: COVER_OPTION_ID,
options: [option],
}, },
coverStatus: 'GENERATED', coverStatus: 'GENERATED',
updatedAt: FieldValue.serverTimestamp(), updatedAt: FieldValue.serverTimestamp(),
@@ -267,82 +207,68 @@ async function performCoverGeneration(project) {
{ merge: true } { merge: true }
) )
logger.info('🏁 [Cover] Process complete', { logger.info('[Cover] MusicLand cover prepared', {
projectId: project.id, projectId: project.id,
successCount: options.length, duration: Date.now() - startedAt,
duration: Date.now() - t0,
}) })
return firstOption.finalUrl return coverUrl
} }
/**
* TRIGGER FIRESTORE
* Déclenché à la création d'un document dans 'tasks/{taskId}'
*/
exports.onTaskCreateGenerateCover = onDocumentCreated( exports.onTaskCreateGenerateCover = onDocumentCreated(
{ {
timeoutSeconds: 540, // 9 minutes max (Imagen peut être lent) timeoutSeconds: 120,
memory: '1GiB', memory: '1GiB',
document: 'tasks/{taskId}', document: 'tasks/{taskId}',
secrets: [GEMINI_API_KEY],
}, },
async (event) => { async (event) => {
const data = event.data?.data() || {} const data = event.data?.data() || {}
const { type, projectId } = data const { type, projectId } = data
const taskId = event.params.taskId const taskId = event.params.taskId
if (!projectId) return // Ignorer les tâches mal formées if (!projectId || !['cover', 'combine'].includes(type)) return
if (!['cover', 'combine'].includes(type)) return // Ignorer les autres types de tâches
logger.info(`🚀 [Task ${taskId}] Started`, { type, projectId }) if (type === 'combine') {
await event.data.ref.update({
status: 'CANCELLED',
error: "La personnalisation photo n'est plus disponible.",
updatedAt: FieldValue.serverTimestamp(),
})
return
}
logger.info(`[Task ${taskId}] MusicLand cover started`, { projectId })
try { try {
// 1. Validation & Setup
if (type === 'combine') {
// Feature désactivée pour le moment
await event.data.ref.update({
status: 'CANCELLED',
error: "La personnalisation photo n'est plus disponible.",
updatedAt: FieldValue.serverTimestamp(),
})
return
}
// Mise à jour statut projet
await refList.projects.doc(projectId).update({ await refList.projects.doc(projectId).update({
coverStatus: 'GENERATING', coverStatus: 'GENERATING',
updatedAt: FieldValue.serverTimestamp(), updatedAt: FieldValue.serverTimestamp(),
}) })
// 2. Chargement Projet const projectSnapshot = await refList.projects.doc(projectId).get()
const projectSnap = await refList.projects.doc(projectId).get() if (!projectSnapshot.exists) {
if (!projectSnap.exists) throw new Error('Projet introuvable') throw new Error('Projet introuvable')
const project = { id: projectId, ...projectSnap.data() }
// Idempotency check (si déjà généré, on ne refait pas)
if (Array.isArray(project?.cover?.options) && project.cover.options.length > 0) {
logger.warn('⚠️ [Task] Cover already exists. Skipping.')
await refList.projects.doc(projectId).update({ coverStatus: 'GENERATED' })
await event.data.ref.update({
status: 'DONE',
info: 'Already generated',
})
return
} }
// 3. Exécution Génération const project = { id: projectId, ...projectSnapshot.data() }
const coverUrl = await performCoverGeneration(project) const existingCover = project.cover || {}
let coverUrl = existingCover.template === COVER_TEMPLATE ? existingCover.result : null
if (!coverUrl) {
coverUrl = await prepareMusiclandCover(project)
} else {
await refList.projects.doc(projectId).update({
coverStatus: 'GENERATED',
updatedAt: FieldValue.serverTimestamp(),
})
}
// 4. Finalisation Tâche
await event.data.ref.update({ await event.data.ref.update({
status: 'DONE', status: 'DONE',
coverUrl, coverUrl,
updatedAt: FieldValue.serverTimestamp(), updatedAt: FieldValue.serverTimestamp(),
}) })
// 5. Notification
if (project.userId) { if (project.userId) {
const projectTitle = project.title || 'ton projet' const projectTitle = project.title || 'ton projet'
await sendNotification({ await sendNotification({
@@ -350,28 +276,30 @@ exports.onTaskCreateGenerateCover = onDocumentCreated(
receiver: project.userId, receiver: project.userId,
receiverCollection: 'users', receiverCollection: 'users',
title: 'Pochette prête !', title: 'Pochette prête !',
message: `La pochette pour "${projectTitle}" a été générée avec succès.`, message: `La pochette pour "${projectTitle}" est prête.`,
data: { data: {
type: ALERT_TYPE?.COVER_GENERATION_SUCCESS, type: ALERT_TYPE?.COVER_GENERATION_SUCCESS,
projectId, projectId,
projectTitle, projectTitle,
coverUrl, coverUrl,
}, },
}).catch((err) => logger.warn('Notification failed', err)) }).catch((error) => logger.warn('Notification failed', error))
} }
} catch (error) { } catch (error) {
logger.error(`🔥 [Task ${taskId}] Failed`, error) logger.error(`[Task ${taskId}] MusicLand cover failed`, error)
await event.data.ref.set(
// Mise à jour erreur Tâche {
await event.data.ref.set({ status: 'ERROR', error: error.message }, { merge: true }) status: 'ERROR',
error: error.message,
// Mise à jour erreur Projet updatedAt: FieldValue.serverTimestamp(),
},
{ merge: true }
)
await refList.projects.doc(projectId).update({ await refList.projects.doc(projectId).update({
coverStatus: 'ERROR', coverStatus: 'ERROR',
updatedAt: FieldValue.serverTimestamp(), updatedAt: FieldValue.serverTimestamp(),
}) })
// Notification Erreur
const projectData = (await refList.projects.doc(projectId).get()).data() const projectData = (await refList.projects.doc(projectId).get()).data()
if (projectData?.userId) { if (projectData?.userId) {
await sendNotification({ await sendNotification({
@@ -379,7 +307,7 @@ exports.onTaskCreateGenerateCover = onDocumentCreated(
receiver: projectData.userId, receiver: projectData.userId,
receiverCollection: 'users', receiverCollection: 'users',
title: 'Échec pochette', title: 'Échec pochette',
message: `Impossible de générer la pochette pour "${projectData.title || 'ton projet'}".`, message: `Impossible de préparer la pochette pour "${projectData.title || 'ton projet'}".`,
data: { data: {
type: ALERT_TYPE?.COVER_GENERATION_FAILED, type: ALERT_TYPE?.COVER_GENERATION_FAILED,
projectId, projectId,
Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

+3 -1
View File
@@ -105,6 +105,7 @@ import placeholder3 from './UI/placeholder3.png'
import placeholder4 from './UI/placeholder4.jpg' import placeholder4 from './UI/placeholder4.jpg'
import profile from './UI/profile.jpg' import profile from './UI/profile.jpg'
import musiclandClub from './UI/musiclandClub.png' import musiclandClub from './UI/musiclandClub.png'
import musiclandPlaybackCover from './UI/musiclandPlaybackCover.png'
import frenchFlag from './icons/frenchFlag.png' import frenchFlag from './icons/frenchFlag.png'
import englishFlag from './icons/englishFlag.png' import englishFlag from './icons/englishFlag.png'
@@ -259,6 +260,7 @@ export const img = {
profile, profile,
goodVibe, goodVibe,
musiclandClub, musiclandClub,
musiclandPlaybackCover,
} }
export const cardsImg = { export const cardsImg = {
@@ -278,4 +280,4 @@ export const planBadges = {
starter: require('./icons/premiumBadge.png'), starter: require('./icons/premiumBadge.png'),
pro: require('./icons/starterBadge.png'), pro: require('./icons/starterBadge.png'),
premium: require('./icons/proBadge.png'), premium: require('./icons/proBadge.png'),
} }
+91
View File
@@ -0,0 +1,91 @@
import { MaterialIcons } from '@expo/vector-icons'
import { VideoView, useVideoPlayer } from 'expo-video'
import React, { useEffect, useMemo, useState } from 'react'
import { Pressable, StyleSheet, Text, View } from 'react-native'
const BackgroundVideo = ({ source }) => {
const [muted, setMuted] = useState(false)
const resolvedSource = useMemo(
() => (source ? (typeof source === 'string' ? { uri: source } : source) : null),
[source]
)
const player = useVideoPlayer(resolvedSource, (nextPlayer) => {
nextPlayer.loop = true
})
useEffect(() => {
if (!source) {
player?.pause?.()
return
}
player.muted = muted
player.play()
}, [muted, player, source])
if (!source) {
return null
}
return (
<View pointerEvents="box-none" style={styles.container}>
<VideoView
player={player}
nativeControls={false}
contentFit="cover"
style={StyleSheet.absoluteFill}
/>
<View pointerEvents="none" style={styles.scrim} />
<Pressable
accessibilityRole="button"
accessibilityLabel={muted ? 'Activer le son' : 'Couper le son'}
onPress={() => setMuted((currentMuted) => !currentMuted)}
style={({ pressed }) => [styles.soundButton, pressed && styles.soundButtonPressed]}
>
<MaterialIcons
name={muted ? 'volume-off' : 'volume-up'}
size={20}
color="#FFFFFF"
/>
<Text style={styles.soundButtonText}>{muted ? 'Activer le son' : 'Couper le son'}</Text>
</Pressable>
</View>
)
}
export default React.memo(BackgroundVideo)
const styles = StyleSheet.create({
container: {
...StyleSheet.absoluteFillObject,
overflow: 'hidden',
},
scrim: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(5, 7, 12, 0.52)',
},
soundButton: {
position: 'absolute',
top: 80,
left: 16,
zIndex: 30,
flexDirection: 'row',
alignItems: 'center',
gap: 8,
paddingHorizontal: 14,
paddingVertical: 10,
borderRadius: 22,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.42)',
backgroundColor: 'rgba(7, 9, 14, 0.68)',
},
soundButtonPressed: {
opacity: 0.78,
},
soundButtonText: {
color: '#FFFFFF',
fontSize: 14,
fontWeight: '600',
},
})
+175
View File
@@ -0,0 +1,175 @@
import { MaterialIcons } from '@expo/vector-icons'
import { Asset } from 'expo-asset'
import React, { useEffect, useRef, useState } from 'react'
import { Pressable, StyleSheet, Text, View } from 'react-native'
const resolveModuleUri = async (source) => {
const asset = Asset.fromModule(source)
if (!asset.localUri && !asset.uri) {
await asset.downloadAsync()
}
return asset.localUri ?? asset.uri ?? null
}
const BackgroundVideo = ({ source, soundButtonStyle = null }) => {
const videoRef = useRef(null)
const [uri, setUri] = useState(null)
const [muted, setMuted] = useState(false)
useEffect(() => {
let isMounted = true
if (!source) {
setUri(null)
return () => {
isMounted = false
}
}
if (typeof source === 'string') {
setUri(source)
return () => {
isMounted = false
}
}
const loadSource = async () => {
try {
const nextUri = await resolveModuleUri(source)
if (isMounted) {
setUri(nextUri)
}
} catch {
if (isMounted) {
setUri(null)
}
}
}
loadSource()
return () => {
isMounted = false
}
}, [source])
useEffect(() => {
const video = videoRef.current
if (!video || !uri) {
return
}
const playResult = video.play()
playResult?.catch?.((error) => {
if (error?.name === 'NotAllowedError') {
setMuted(true)
video.muted = true
video.play()?.catch?.(() => {})
}
})
}, [uri])
useEffect(() => {
const video = videoRef.current
if (!video) {
return
}
video.muted = muted
if (video.paused) {
video.play()?.catch?.(() => {})
}
}, [muted])
if (!uri) {
return null
}
return (
<View pointerEvents="box-none" style={styles.container}>
<video
ref={videoRef}
src={uri}
style={styles.video}
playsInline
autoPlay
loop
muted={muted}
controls={false}
/>
<View pointerEvents="none" style={styles.scrim} />
<Pressable
accessibilityRole="button"
accessibilityLabel={muted ? 'Activer le son' : 'Couper le son'}
onPress={() => setMuted((currentMuted) => !currentMuted)}
style={({ hovered, pressed }) => [
styles.soundButton,
soundButtonStyle,
hovered && styles.soundButtonHovered,
pressed && styles.soundButtonPressed,
]}
>
<MaterialIcons
name={muted ? 'volume-off' : 'volume-up'}
size={20}
color="#FFFFFF"
/>
<Text style={styles.soundButtonText}>{muted ? 'Activer le son' : 'Couper le son'}</Text>
</Pressable>
</View>
)
}
export default React.memo(BackgroundVideo)
const styles = StyleSheet.create({
container: {
...StyleSheet.absoluteFillObject,
overflow: 'hidden',
},
video: {
position: 'absolute',
top: 0,
right: 0,
bottom: 0,
left: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
},
scrim: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(5, 7, 12, 0.52)',
},
soundButton: {
position: 'absolute',
top: 78,
left: 24,
zIndex: 30,
flexDirection: 'row',
alignItems: 'center',
gap: 8,
paddingHorizontal: 14,
paddingVertical: 10,
borderRadius: 22,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.42)',
backgroundColor: 'rgba(7, 9, 14, 0.68)',
cursor: 'pointer',
},
soundButtonHovered: {
backgroundColor: 'rgba(7, 9, 14, 0.82)',
},
soundButtonPressed: {
opacity: 0.78,
},
soundButtonText: {
color: '#FFFFFF',
fontSize: 14,
fontWeight: '600',
},
})
+92
View File
@@ -0,0 +1,92 @@
import { Image as ExpoImage } from 'expo-image'
import React from 'react'
import { StyleSheet, Text, View } from 'react-native'
import { img } from '../assets'
import { FONT_FAMILY } from '../styles/Fonts'
const MusiclandPlaybackCover = ({
artistName,
profilePictureURL,
coverUrl,
style,
}) => {
const displayName =
typeof artistName === 'string' && artistName.trim() ? artistName.trim() : 'MusicLand'
return (
<View style={[styles.container, style]}>
<ExpoImage
source={coverUrl ? { uri: coverUrl } : img.musiclandPlaybackCover}
contentFit="cover"
cachePolicy="memory-disk"
priority="high"
style={StyleSheet.absoluteFillObject}
/>
{!coverUrl && profilePictureURL ? (
<ExpoImage
source={{ uri: profilePictureURL }}
contentFit="cover"
cachePolicy="memory-disk"
style={styles.profilePicture}
/>
) : null}
{!coverUrl ? (
<>
<Text
adjustsFontSizeToFit
minimumFontScale={0.55}
numberOfLines={1}
style={styles.artistName}
>
{displayName}
</Text>
<Text style={styles.playbacker}>PLAYBACKER</Text>
</>
) : null}
</View>
)
}
const styles = StyleSheet.create({
container: {
width: '100%',
aspectRatio: 1,
borderRadius: 20,
overflow: 'hidden',
position: 'relative',
},
profilePicture: {
position: 'absolute',
width: '22.3%',
aspectRatio: 1,
left: '38.85%',
top: '44.3%',
borderRadius: 999,
},
artistName: {
position: 'absolute',
top: '76.5%',
left: '16%',
width: '68%',
color: '#FFFFFF',
fontFamily: FONT_FAMILY.InterRegular,
fontSize: 21,
lineHeight: 26,
letterSpacing: 1.2,
textAlign: 'center',
},
playbacker: {
position: 'absolute',
bottom: '3%',
left: '10%',
width: '80%',
color: '#FFFFFF',
fontFamily: FONT_FAMILY.InterRegular,
fontSize: 20,
lineHeight: 25,
letterSpacing: 1.4,
textAlign: 'center',
},
})
export default React.memo(MusiclandPlaybackCover)
+1 -1
View File
@@ -33,7 +33,7 @@ const useNavigateToMusicDetails = () => {
if (resolvedProject && isOwner) { if (resolvedProject && isOwner) {
selectProject?.(projectId) selectProject?.(projectId)
const stageAction = getStageAction('beatmaker', resolvedProject) const stageAction = getStageAction('beatmaker', resolvedProject)
const targetRoute = stageAction?.route || Routes.Compose const targetRoute = stageAction?.route || Routes.ComposeSong
navigate(targetRoute, stageAction?.params) navigate(targetRoute, stageAction?.params)
return true return true
} }
+40 -17
View File
@@ -72,8 +72,8 @@ const generateNonce = (length = 32) => {
return result return result
} }
const ensureUserDocument = async (user, overrides = {}) => { const ensureUserDocument = async (user, overrides = {}, isNewUser = false) => {
if (!user?.uid) return if (!user?.uid) return null
try { try {
const docRef = usersRef.doc(user.uid) const docRef = usersRef.doc(user.uid)
@@ -88,6 +88,9 @@ const ensureUserDocument = async (user, overrides = {}) => {
payload.createdAt = firebase.firestore.FieldValue.serverTimestamp() payload.createdAt = firebase.firestore.FieldValue.serverTimestamp()
if (user.email) payload.email = user.email if (user.email) payload.email = user.email
payload.emailNotifications = true payload.emailNotifications = true
if (isNewUser) {
payload.registrationFlowRequired = true
}
} else if (typeof existingData?.emailNotifications === 'undefined') { } else if (typeof existingData?.emailNotifications === 'undefined') {
payload.emailNotifications = true payload.emailNotifications = true
} }
@@ -122,8 +125,16 @@ const ensureUserDocument = async (user, overrides = {}) => {
} }
await docRef.set(payload, { merge: true }) await docRef.set(payload, { merge: true })
return {
isNewUser,
userData: {
...existingData,
...payload,
},
}
} catch (error) { } catch (error) {
console.log('ensureUserDocument error', error?.message) console.log('ensureUserDocument error', error?.message)
throw error
} }
} }
@@ -221,10 +232,14 @@ const useSocialAuth = ({ onSuccess } = {}) => {
const credential = firebase.auth.GoogleAuthProvider.credential(idToken) const credential = firebase.auth.GoogleAuthProvider.credential(idToken)
await firebase.auth().signInWithCredential(credential) const userCredential = await firebase.auth().signInWithCredential(credential)
await ensureUserDocument(firebase.auth().currentUser) const authResult = await ensureUserDocument(
userCredential?.user,
{},
userCredential?.additionalUserInfo?.isNewUser === true
)
if (typeof onSuccess === 'function') { if (typeof onSuccess === 'function') {
await onSuccess() await onSuccess(authResult)
} }
} catch (error) { } catch (error) {
console.log('Google sign-in error', error?.message) console.log('Google sign-in error', error?.message)
@@ -298,10 +313,14 @@ const useSocialAuth = ({ onSuccess } = {}) => {
const provider = new firebase.auth.GoogleAuthProvider() const provider = new firebase.auth.GoogleAuthProvider()
provider.addScope('profile') provider.addScope('profile')
provider.addScope('email') provider.addScope('email')
await firebase.auth().signInWithPopup(provider) const userCredential = await firebase.auth().signInWithPopup(provider)
await ensureUserDocument(firebase.auth().currentUser) const authResult = await ensureUserDocument(
userCredential?.user,
{},
userCredential?.additionalUserInfo?.isNewUser === true
)
if (typeof onSuccess === 'function') { if (typeof onSuccess === 'function') {
await onSuccess() await onSuccess(authResult)
} }
setIsLoading(false) setIsLoading(false)
} catch (error) { } catch (error) {
@@ -398,17 +417,21 @@ const useSocialAuth = ({ onSuccess } = {}) => {
const firstName = fullName.givenName?.trim() || null const firstName = fullName.givenName?.trim() || null
const lastName = fullName.familyName?.trim() || null const lastName = fullName.familyName?.trim() || null
await ensureUserDocument(userCredential?.user, { const authResult = await ensureUserDocument(
firstName, userCredential?.user,
lastName, {
displayName: firstName,
userCredential?.user?.displayName || lastName,
[firstName, lastName].filter(Boolean).join(' ') || displayName:
null, userCredential?.user?.displayName ||
}) [firstName, lastName].filter(Boolean).join(' ') ||
null,
},
userCredential?.additionalUserInfo?.isNewUser === true
)
if (typeof onSuccess === 'function') { if (typeof onSuccess === 'function') {
await onSuccess() await onSuccess(authResult)
} }
setIsLoading(false) setIsLoading(false)
} catch (error) { } catch (error) {
+4 -1
View File
@@ -49,6 +49,8 @@ export default ({
connect = false, connect = false,
blurIntensity = 0, blurIntensity = 0,
backgroundColor = '#000', backgroundColor = '#000',
backgroundContent = null,
coinBadgeContainerStyle = null,
showCoin = true, showCoin = true,
showReturnHome = false, showReturnHome = false,
onReturnHome = null, onReturnHome = null,
@@ -209,8 +211,9 @@ export default ({
}} }}
/> />
) : null} ) : null}
{backgroundContent}
{coinBadgeContainerVisible ? ( {coinBadgeContainerVisible ? (
<View style={styles.coinBadgeContainer}> <View style={[styles.coinBadgeContainer, coinBadgeContainerStyle]}>
{showCoinBadge ? ( {showCoinBadge ? (
<View style={styles.coinRow}> <View style={styles.coinRow}>
<Pressable <Pressable
+2
View File
@@ -3,6 +3,7 @@ import React from 'react'
import { Platform } from 'react-native' import { Platform } from 'react-native'
import CreatePassword from '../screens/CreatePassword' import CreatePassword from '../screens/CreatePassword'
import CreatePseudo from '../screens/CreatePseudo' import CreatePseudo from '../screens/CreatePseudo'
import RegistrationChoice from '../screens/RegistrationChoice'
import ForgotPassword from '../screens/ForgotPassword' import ForgotPassword from '../screens/ForgotPassword'
import LandingPage from '../screens/LandingPage' import LandingPage from '../screens/LandingPage'
import AllMyList from '../screens/Library/AllMyList' import AllMyList from '../screens/Library/AllMyList'
@@ -95,6 +96,7 @@ const baseScreens = [
{ name: Routes.Onboarding, component: Onboarding }, { name: Routes.Onboarding, component: Onboarding },
{ name: Routes.Login, component: Login, title: 'Connexion' }, { name: Routes.Login, component: Login, title: 'Connexion' },
{ name: Routes.LandingPage, component: LandingPage }, { name: Routes.LandingPage, component: LandingPage },
{ name: Routes.RegistrationChoice, component: RegistrationChoice },
{ {
name: Routes.ForgotPassword, name: Routes.ForgotPassword,
component: ForgotPassword, component: ForgotPassword,
+1
View File
@@ -7,6 +7,7 @@ export const Routes = {
Register: 'Register', Register: 'Register',
CreatePassword: 'CreatePassword', CreatePassword: 'CreatePassword',
CreatePseudo: 'CreatePseudo', CreatePseudo: 'CreatePseudo',
RegistrationChoice: 'RegistrationChoice',
LandingPage: 'LandingPage', LandingPage: 'LandingPage',
BottomTab: 'BottomTab', BottomTab: 'BottomTab',
Welcome: 'Welcome', Welcome: 'Welcome',
+1
View File
@@ -56,6 +56,7 @@ const HIDDEN_ROUTE_NAMES = new Set([
Routes.Login, Routes.Login,
Routes.ResetPassword, Routes.ResetPassword,
Routes.Register, Routes.Register,
Routes.RegistrationChoice,
]) ])
const noopAsync = async () => {} const noopAsync = async () => {}
+3 -2
View File
@@ -13,7 +13,7 @@ import firebase, { usersRef } from '../config/firebase'
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'
import { navigate } from '../navigation/NavigationService' 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'
@@ -115,13 +115,14 @@ const CreatePassword = () => {
...(birthDate ? { birthDate } : {}), ...(birthDate ? { birthDate } : {}),
...(country?.code ? { countryCode: country.code } : {}), ...(country?.code ? { countryCode: country.code } : {}),
...(country?.name ? { countryName: country.name } : {}), ...(country?.name ? { countryName: country.name } : {}),
registrationFlowRequired: true,
createdAt: firebase.firestore.FieldValue.serverTimestamp(), createdAt: firebase.firestore.FieldValue.serverTimestamp(),
updatedAt: firebase.firestore.FieldValue.serverTimestamp(), updatedAt: firebase.firestore.FieldValue.serverTimestamp(),
}, },
{ merge: true } { merge: true }
) )
setTooltip({ text: 'Compte créé, continuons', type: 'success' }) setTooltip({ text: 'Compte créé, continuons', type: 'success' })
navigate(Routes.BottomTab) reset({ index: 0, routes: [{ name: Routes.RegistrationChoice }] })
} catch (e) { } catch (e) {
console.log('Register error', e?.message) console.log('Register error', e?.message)
const message = const message =
+2 -2
View File
@@ -295,7 +295,7 @@ const Home = ({ navigation, route }) => {
if (selectedProject?.id !== currentProject.id) { if (selectedProject?.id !== currentProject.id) {
selectProject(currentProject.id) selectProject(currentProject.id)
} }
const targetRoute = beatmakerStage?.route || Routes.Compose const targetRoute = beatmakerStage?.route || Routes.ComposeSong
navigate(targetRoute, beatmakerStage?.params) navigate(targetRoute, beatmakerStage?.params)
}, },
}, },
@@ -324,7 +324,7 @@ const Home = ({ navigation, route }) => {
const songwriterAction = useMemo(() => getStageAction('songwriter', null), []) const songwriterAction = useMemo(() => getStageAction('songwriter', null), [])
const handleStartNew = useCallback(async () => { const handleStartNew = useCallback(async () => {
const targetRoute = songwriterAction?.route || Routes.WritingLyrics const targetRoute = songwriterAction?.route || Routes.CreateLyricsWithAi
const targetParams = songwriterAction?.params const targetParams = songwriterAction?.params
try { try {
+2
View File
@@ -15,6 +15,8 @@ const ClubCard = ({ onPress, containerStyle = null, variant = 'compact' }) => {
return ( return (
<Pressable <Pressable
onPress={onPress} onPress={onPress}
accessibilityRole="button"
accessibilityLabel="Club MusicLand"
style={({ pressed }) => [ style={({ pressed }) => [
styles.card, styles.card,
cardBaseStyle, cardBaseStyle,
+2 -2
View File
@@ -174,8 +174,8 @@ const StageCard = ({
isMobileVariant ? { textAlign: 'center' } : { textAlign: 'left', marginRight: 10 }, isMobileVariant ? { textAlign: 'center' } : { textAlign: 'left', marginRight: 10 },
{ {
fontFamily: FONT_FAMILY.InterRegular, fontFamily: FONT_FAMILY.InterRegular,
fontSize: 13, fontSize: 14,
lineHeight: 20, lineHeight: 21,
color: Palette.white, color: Palette.white,
}, },
]} ]}
+9 -3
View File
@@ -9,7 +9,7 @@ import { background } from '../assets'
import GradientButton from '../components/GradientButton.js' import GradientButton from '../components/GradientButton.js'
import { Input } from '../components/Input.js' import { Input } from '../components/Input.js'
import ItemContainer from '../components/ItemContainer/ItemContainer' import ItemContainer from '../components/ItemContainer/ItemContainer'
import firebase from '../config/firebase' import firebase, { usersRef } from '../config/firebase'
import useSocialAuth from '../hooks/useSocialAuth' import useSocialAuth from '../hooks/useSocialAuth'
import { isWeb } from '../hooks/useLayoutType' import { isWeb } from '../hooks/useLayoutType'
import Page from '../layouts/Page.js' import Page from '../layouts/Page.js'
@@ -17,16 +17,22 @@ import { Routes } from '../navigation'
import { navigate } from '../navigation/NavigationService.js' import { navigate } from '../navigation/NavigationService.js'
import { FONT_FAMILY } from '../styles/Fonts.js' import { FONT_FAMILY } from '../styles/Fonts.js'
import Palette from '../styles/Palette.js' import Palette from '../styles/Palette.js'
import { getRouteAfterAuthentication } from '../utils/registrationFlow'
export default ({ navigation }) => { export default ({ navigation }) => {
const [, setTooltip] = useGlobal('_tooltip') const [, setTooltip] = useGlobal('_tooltip')
const [email, setEmail] = useState(__DEV__ ? 'az@az.az' : '') const [email, setEmail] = useState(__DEV__ ? 'az@az.az' : '')
const [password, setPassword] = useState(__DEV__ ? 'Minuit33' : '') const [password, setPassword] = useState(__DEV__ ? 'Minuit33' : '')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const afterLoginNavigate = useCallback(async () => { const afterLoginNavigate = useCallback(async (authResult) => {
const uid = firebase.auth().currentUser?.uid const uid = firebase.auth().currentUser?.uid
if (!uid) throw new Error('Aucun utilisateur après connexion') if (!uid) throw new Error('Aucun utilisateur après connexion')
navigation.reset({ index: 0, routes: [{ name: Routes.BottomTab }] }) const userData =
authResult?.userData || (await usersRef.doc(uid).get()).data() || {}
navigation.reset({
index: 0,
routes: [{ name: getRouteAfterAuthentication(userData) }],
})
}, [navigation]) }, [navigation])
const { const {
+18 -16
View File
@@ -1,8 +1,8 @@
import React, { useCallback, useState } from 'react' import React, { useCallback } from 'react'
import { Image, StyleSheet, View } from 'react-native' import { StyleSheet, View } from 'react-native'
import { ai, background } from '../../assets' import { background } from '../../assets'
import BackgroundVideo from '../../components/BackgroundVideo'
import BorderGradientButton from '../../components/BorderGradientButton' import BorderGradientButton from '../../components/BorderGradientButton'
import FullscreenIntroVideo from '../../components/FullscreenIntroVideo'
import GradientButton from '../../components/GradientButton' import GradientButton from '../../components/GradientButton'
import MusicLandHeader from '../../components/MusicLandHeader' import MusicLandHeader from '../../components/MusicLandHeader'
import Page from '../../layouts/Page' import Page from '../../layouts/Page'
@@ -15,13 +15,7 @@ import { gutters } from '../../styles'
const Playback = ({ route, navigation }) => { const Playback = ({ route, navigation }) => {
const { project, returnToAdventureModal = false } = route.params || {} const { project, returnToAdventureModal = false } = route.params || {}
const { videos } = useUser() const { videos } = useUser()
// const benhaiUrl = isWeb ? videos?.benhaiWeb || null : videos?.benhai;
const theoUrl = isWeb ? videos?.theoWeb : videos?.theo const theoUrl = isWeb ? videos?.theoWeb : videos?.theo
const [showIntro, setShowIntro] = useState(theoUrl)
const handleCloseIntro = () => {
setShowIntro(null)
}
const handleBackPress = useCallback(() => { const handleBackPress = useCallback(() => {
if (returnToAdventureModal) { if (returnToAdventureModal) {
@@ -39,6 +33,13 @@ const Playback = ({ route, navigation }) => {
<Page <Page
headerType="NONE" headerType="NONE"
backgroundImg={isWeb ? background.playbackWeb : background.playbackMobile} backgroundImg={isWeb ? background.playbackWeb : background.playbackMobile}
backgroundContent={
<BackgroundVideo
source={theoUrl}
soundButtonStyle={isWeb ? styles.rightSideControl : undefined}
/>
}
containerStyle={isWeb ? styles.videoPage : undefined}
> >
{/* <Image source={ai.theo} style={styles.img} resizeMode="contain" /> */} {/* <Image source={ai.theo} style={styles.img} resizeMode="contain" /> */}
<MusicLandHeader onPressBack={handleBackPress} progress={25} /> <MusicLandHeader onPressBack={handleBackPress} progress={25} />
@@ -58,7 +59,6 @@ const Playback = ({ route, navigation }) => {
{/* <BorderGradientButton title="Importer une vidéo" /> */} {/* <BorderGradientButton title="Importer une vidéo" /> */}
</View> </View>
</View> </View>
<FullscreenIntroVideo url={showIntro} visible={showIntro} onClose={handleCloseIntro} />
</Page> </Page>
) )
} }
@@ -66,10 +66,12 @@ const Playback = ({ route, navigation }) => {
export default Playback export default Playback
const styles = StyleSheet.create({ const styles = StyleSheet.create({
img: { videoPage: {
width: '100%', alignSelf: 'flex-start',
height: '70%', marginLeft: '4%',
position: 'absolute', },
bottom: -40, rightSideControl: {
right: 24,
left: 'auto',
}, },
}) })
+22 -2
View File
@@ -4,6 +4,7 @@ import { Linking, StyleSheet, Text, View } from 'react-native'
import useMinuit from 'react-native-minuit/src/hooks/useMinuit.js' import useMinuit from 'react-native-minuit/src/hooks/useMinuit.js'
import { background } from '../../assets' import { background } from '../../assets'
import AppCheckbox from '../../components/AppCheckbox' import AppCheckbox from '../../components/AppCheckbox'
import BackgroundVideo from '../../components/BackgroundVideo'
import BorderGradientButton from '../../components/BorderGradientButton' 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'
@@ -15,11 +16,12 @@ import { Routes } from '../../navigation'
import { useUser } from '../../providers/UserDataProvider' import { useUser } from '../../providers/UserDataProvider'
import { Palette, gutters } from '../../styles' import { Palette, gutters } from '../../styles'
import { FONT_FAMILY } from '../../styles/Fonts' import { FONT_FAMILY } from '../../styles/Fonts'
import { isWeb } from '../../hooks/useLayoutType'
const PublishYoutube = () => { const PublishYoutube = () => {
const route = useRoute() const route = useRoute()
const routeProjectId = route?.params?.projectId ?? null const routeProjectId = route?.params?.projectId ?? null
const { userProjects = [], selectedProject } = useUser() const { userProjects = [], selectedProject, videos } = useUser()
const { setTooltip } = useMinuit() const { setTooltip } = useMinuit()
const project = useMemo(() => { const project = useMemo(() => {
@@ -33,6 +35,7 @@ const PublishYoutube = () => {
}, [routeProjectId, selectedProject, userProjects]) }, [routeProjectId, selectedProject, userProjects])
const projectId = project?.id || routeProjectId || null const projectId = project?.id || routeProjectId || null
const introVideo = isWeb ? videos?.benhaiWeb : videos?.benhai
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [hasAcceptedPublication, setHasAcceptedPublication] = useState(true) const [hasAcceptedPublication, setHasAcceptedPublication] = useState(true)
@@ -194,7 +197,17 @@ const PublishYoutube = () => {
}, []) }, [])
return ( return (
<Page backgroundImg={background.playbackBG2} headerType="NONE"> <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} /> <MusicLandHeader progress={100} onPressBack={goBack} />
<View <View
style={{ style={{
@@ -293,6 +306,13 @@ const PublishYoutube = () => {
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
videoPage: {
alignSelf: 'center',
},
rightSideControl: {
right: 24,
left: 'auto',
},
consentContainer: { consentContainer: {
gap: 6, gap: 6,
}, },
+6 -2
View File
@@ -27,6 +27,7 @@ import { Routes } from '../navigation'
import { navigate, reset } from '../navigation/NavigationService' import { navigate, reset } from '../navigation/NavigationService'
import { Palette } from '../styles' import { Palette } from '../styles'
import { FONT_FAMILY } from '../styles/Fonts' import { FONT_FAMILY } from '../styles/Fonts'
import { getRouteAfterAuthentication } from '../utils/registrationFlow'
const LANGUAGE_STORAGE_KEY = 'preferredLanguage' const LANGUAGE_STORAGE_KEY = 'preferredLanguage'
@@ -39,10 +40,13 @@ const Register = () => {
const [showDatePicker, setShowDatePicker] = useState(false) const [showDatePicker, setShowDatePicker] = useState(false)
const [, setTooltip] = useGlobal('_tooltip') const [, setTooltip] = useGlobal('_tooltip')
const afterSocialAuth = useCallback(async () => { const afterSocialAuth = useCallback(async (authResult) => {
const uid = firebase.auth().currentUser?.uid const uid = firebase.auth().currentUser?.uid
if (!uid) throw new Error('Aucun utilisateur après connexion') if (!uid) throw new Error('Aucun utilisateur après connexion')
reset({ index: 0, routes: [{ name: Routes.BottomTab }] }) reset({
index: 0,
routes: [{ name: getRouteAfterAuthentication(authResult?.userData) }],
})
}, []) }, [])
const { const {
+171
View File
@@ -0,0 +1,171 @@
import React, { useCallback, useState } from 'react'
import { StyleSheet, Text, View } from 'react-native'
import { useGlobal } from 'reactn'
import { background } from '../assets'
import BorderGradientButton from '../components/BorderGradientButton'
import GradientButton from '../components/GradientButton'
import ItemContainer from '../components/ItemContainer/ItemContainer'
import { isWeb } from '../hooks/useLayoutType'
import Page from '../layouts/Page'
import { Routes } from '../navigation'
import { navigate, reset } from '../navigation/NavigationService'
import { Palette } from '../styles'
import { FONT_FAMILY } from '../styles/Fonts'
import {
completeRegistrationFlow,
getExperienceReset,
isGlobalListeningLaunchPeriodActive,
} from '../utils/registrationFlow'
const RegistrationChoice = () => {
const [loadingChoice, setLoadingChoice] = useState(null)
const [, setTooltip] = useGlobal('_tooltip')
const openPayment = useCallback((experience) => {
navigate(Routes.Payments, {
registrationFlow: true,
registrationChoice: experience,
})
}, [])
const handleListen = useCallback(async () => {
if (!isGlobalListeningLaunchPeriodActive()) {
openPayment('listen')
return
}
try {
setLoadingChoice('listen')
await completeRegistrationFlow('listen')
reset(getExperienceReset('listen'))
} catch (error) {
setTooltip({
type: 'error',
text: error?.message || 'Impossible douvrir lespace Écoute',
})
} finally {
setLoadingChoice(null)
}
}, [openPayment, setTooltip])
const handleCreation = useCallback(() => {
openPayment('creation')
}, [openPayment])
return (
<Page
width={isWeb ? 720 : null}
backgroundImg={isWeb ? background.loginBgWeb : background.homeBG}
headerType="NONE"
title="Choisis ton expérience"
>
<View style={styles.pageContent}>
<ItemContainer
height={isWeb ? 480 : undefined}
disableKeyboardHeight
style={styles.container}
>
<View style={styles.content}>
<View style={styles.heading}>
<Text style={styles.title}>Que veux-tu faire en premier ?</Text>
<Text style={styles.subtitle}>
Tu pourras passer librement de la création à lécoute après cette étape.
</Text>
</View>
<View style={styles.choice}>
<View style={styles.choiceText}>
<Text style={styles.choiceTitle}>Création</Text>
<Text style={styles.choiceDescription}>
Commence ton parcours avec les 10 crédits offerts à ton inscription.
</Text>
</View>
<BorderGradientButton
title="Créer"
onPress={handleCreation}
disabled={loadingChoice !== null}
containerStyle={styles.button}
/>
</View>
<View style={styles.divider} />
<View style={styles.choice}>
<View style={styles.choiceText}>
<Text style={styles.choiceTitle}>Écoute</Text>
<Text style={styles.choiceDescription}>
Retrouve les créations de la communauté dans lespace Streaming.
</Text>
</View>
<GradientButton
title={loadingChoice === 'listen' ? 'Ouverture...' : 'Écouter'}
onPress={handleListen}
disabled={loadingChoice !== null}
containerStyle={styles.button}
/>
</View>
</View>
</ItemContainer>
</View>
</Page>
)
}
const styles = StyleSheet.create({
pageContent: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
container: {
width: '100%',
},
content: {
gap: 24,
paddingHorizontal: 8,
},
heading: {
alignItems: 'center',
gap: 8,
marginBottom: 4,
},
title: {
color: Palette.white,
fontFamily: FONT_FAMILY.InterSemiBold,
fontSize: 24,
textAlign: 'center',
},
subtitle: {
color: Palette.gray,
fontFamily: FONT_FAMILY.InterRegular,
fontSize: 14,
lineHeight: 20,
textAlign: 'center',
},
choice: {
gap: 16,
},
choiceText: {
gap: 6,
},
choiceTitle: {
color: Palette.white,
fontFamily: FONT_FAMILY.InterSemiBold,
fontSize: 19,
},
choiceDescription: {
color: Palette.gray,
fontFamily: FONT_FAMILY.InterRegular,
fontSize: 14,
lineHeight: 20,
},
button: {
width: '100%',
},
divider: {
height: 1,
backgroundColor: Palette.ultraLightWhite,
},
})
export default RegistrationChoice
+2 -1
View File
@@ -6,6 +6,7 @@ import { Routes } from '../navigation'
import { SplashAnimationContext } from '../providers/SplashAnimationProvider' import { SplashAnimationContext } from '../providers/SplashAnimationProvider'
import { UserDataContext } from '../providers/UserDataProvider' import { UserDataContext } from '../providers/UserDataProvider'
import { getRouteAfterAuthentication } from '../utils/registrationFlow'
export default ({ navigation }) => { export default ({ navigation }) => {
const { setCurrentUserData } = useContext(UserDataContext) const { setCurrentUserData } = useContext(UserDataContext)
@@ -28,7 +29,7 @@ export default ({ navigation }) => {
index: 0, index: 0,
routes: [ routes: [
{ {
name: Routes.BottomTab, name: getRouteAfterAuthentication(user),
}, },
], ],
}) })
+14 -3
View File
@@ -3,6 +3,7 @@ import { useRoute } from '@react-navigation/native'
import { Dimensions, Modal, Text, View } from 'react-native' import { Dimensions, Modal, Text, View } from 'react-native'
import SwiperFlatList from 'react-native-swiper-flatlist' import SwiperFlatList from 'react-native-swiper-flatlist'
import { background, icons } from '../../assets' import { background, icons } from '../../assets'
import BackgroundVideo from '../../components/BackgroundVideo'
import BorderGradientButton from '../../components/BorderGradientButton' 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'
@@ -41,8 +42,14 @@ const ComposeSong = () => {
const [progress, setProgress] = useState(18) const [progress, setProgress] = useState(18)
const [containerLayout, setContainerLayout] = useState(null) const [containerLayout, setContainerLayout] = useState(null)
const route = useRoute() const route = useRoute()
const { selectedProjectId, selectedProject, updateProjectData, currentUserData, currentUID } = const {
useUser() selectedProjectId,
selectedProject,
updateProjectData,
currentUserData,
currentUID,
videos,
} = useUser()
// Selections state // Selections state
const [genres, setGenres] = useState([]) const [genres, setGenres] = useState([])
@@ -286,7 +293,11 @@ const ComposeSong = () => {
]) ])
return ( return (
<Page backgroundImg={background.studioBG2} headerType="NONE"> <Page
backgroundImg={background.studioBG2}
backgroundContent={<BackgroundVideo source={videos?.malik} />}
headerType="NONE"
>
<MusicLandHeader onPressBack={onPressBack} progress={progress} logo={icons.musicLandStudio} /> <MusicLandHeader onPressBack={onPressBack} progress={progress} logo={icons.musicLandStudio} />
<View style={{ flex: 1, paddingBottom: gutters, gap: 48 }}> <View style={{ flex: 1, paddingBottom: gutters, gap: 48 }}>
<View <View
+38 -4
View File
@@ -1,7 +1,8 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useRoute } from '@react-navigation/native' import { useRoute } from '@react-navigation/native'
import { FlatList, Modal, Text, View, useWindowDimensions } from 'react-native' import { FlatList, Modal, StyleSheet, Text, View, useWindowDimensions } from 'react-native'
import { background } from '../../assets' import { background } from '../../assets'
import BackgroundVideo from '../../components/BackgroundVideo'
import BorderGradientButton from '../../components/BorderGradientButton' 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'
@@ -47,8 +48,14 @@ const ComposeSong = () => {
}, [windowWidth]) }, [windowWidth])
const containerWidth = parentLayout?.width || estimatedContainerWidth const containerWidth = parentLayout?.width || estimatedContainerWidth
const route = useRoute() const route = useRoute()
const { selectedProjectId, selectedProject, updateProjectData, currentUserData, currentUID } = const {
useUser() selectedProjectId,
selectedProject,
updateProjectData,
currentUserData,
currentUID,
videos,
} = useUser()
const [genres, setGenres] = useState([]) const [genres, setGenres] = useState([])
const [voice, setVoice] = useState({}) const [voice, setVoice] = useState({})
@@ -288,7 +295,18 @@ const ComposeSong = () => {
} }
return ( return (
<Page backgroundImg={background.studioBG2} headerType="NONE"> <Page
backgroundImg={background.studioBG2}
backgroundContent={
<BackgroundVideo
source={videos?.malikWeb}
soundButtonStyle={styles.rightSideControl}
/>
}
coinBadgeContainerStyle={styles.rightSideCredits}
containerStyle={styles.videoQuestionPage}
headerType="NONE"
>
<MusicLandHeader <MusicLandHeader
onPressBack={onPressBack} onPressBack={onPressBack}
progress={progress} progress={progress}
@@ -479,3 +497,19 @@ const ComposeSong = () => {
} }
export default ComposeSong export default ComposeSong
const styles = StyleSheet.create({
videoQuestionPage: {
alignSelf: 'flex-start',
marginLeft: '4%',
},
rightSideControl: {
right: 24,
left: 'auto',
},
rightSideCredits: {
right: 24,
left: 'auto',
alignItems: 'flex-end',
},
})
+2 -2
View File
@@ -111,7 +111,7 @@ const Studio = () => {
} else { } else {
if (!selectedProject?.id) return if (!selectedProject?.id) return
selectProject(selectedProject.id) selectProject(selectedProject.id)
navigate(Routes.Compose) navigate(Routes.ComposeSong)
} }
}} }}
/> />
@@ -130,7 +130,7 @@ const Studio = () => {
/> />
{!selectedProject?.coverUrl && ( {!selectedProject?.coverUrl && (
<GradientButton <GradientButton
title="Générer la pochette" title="Voir ma pochette"
containerStyle={{ containerStyle={{
marginTop: responsiveHeight(2), marginTop: responsiveHeight(2),
}} }}
+72 -5
View File
@@ -14,6 +14,7 @@ import { BlurView } from 'expo-blur'
import { Image as ExpoImage } from 'expo-image' import { Image as ExpoImage } from 'expo-image'
import { useSafeAreaInsets } from 'react-native-safe-area-context' import { useSafeAreaInsets } from 'react-native-safe-area-context'
import GradientButton from '../components/GradientButton' import GradientButton from '../components/GradientButton'
import BorderGradientButton from '../components/BorderGradientButton'
import CreditAmount from '../components/CreditAmount' import CreditAmount from '../components/CreditAmount'
import Page from '../layouts/Page' import Page from '../layouts/Page'
import { background, icons, planBadges } from '../assets' import { background, icons, planBadges } from '../assets'
@@ -22,7 +23,7 @@ import { FONT_FAMILY } from '../styles/Fonts'
import { isWeb } from '../hooks/useLayoutType' import { isWeb } from '../hooks/useLayoutType'
import { useStripe } from '../providers/StripeProvider' import { useStripe } from '../providers/StripeProvider'
import { useUserData } from '../providers/UserDataProvider' import { useUserData } from '../providers/UserDataProvider'
import { goBack, navigate } from '../navigation/NavigationService' import { goBack, navigate, reset } from '../navigation/NavigationService'
import { Routes } from '../navigation/Routes' import { Routes } from '../navigation/Routes'
import { import {
COIN_PACK_DETAILS, COIN_PACK_DETAILS,
@@ -36,6 +37,7 @@ import {
SUBSCRIPTION_PLAN_SYNONYMS, SUBSCRIPTION_PLAN_SYNONYMS,
SUBSCRIPTION_PRICE_ID_BY_PERIOD, SUBSCRIPTION_PRICE_ID_BY_PERIOD,
} from '../utils/subscriptionCardDisplay' } from '../utils/subscriptionCardDisplay'
import { completeRegistrationFlow, getExperienceReset } from '../utils/registrationFlow'
const formatCurrency = (amount, currency = 'eur') => { const formatCurrency = (amount, currency = 'eur') => {
if (typeof amount !== 'number') { if (typeof amount !== 'number') {
@@ -302,6 +304,10 @@ export default function Subscriptions() {
const route = useRoute() const route = useRoute()
const insets = useSafeAreaInsets() const insets = useSafeAreaInsets()
const isMobile = !isWeb const isMobile = !isWeb
const isRegistrationFlow = route?.params?.registrationFlow === true
const registrationChoice = route?.params?.registrationChoice
const canSkipRegistrationPayment =
isRegistrationFlow && registrationChoice === 'creation'
const initialPack = React.useMemo(() => { const initialPack = React.useMemo(() => {
const rawPack = route?.params?.pack const rawPack = route?.params?.pack
return typeof rawPack === 'string' ? rawPack.toLowerCase() : null return typeof rawPack === 'string' ? rawPack.toLowerCase() : null
@@ -318,12 +324,13 @@ export default function Subscriptions() {
createSubscriptionCheckout, createSubscriptionCheckout,
createCoinPackCheckout, createCoinPackCheckout,
} = useStripe() } = useStripe()
const { currentUserData } = useUserData() const { currentUserData, hasActiveSubscription } = useUserData()
const [selectedPeriodKey, setSelectedPeriodKey] = React.useState('monthly') const [selectedPeriodKey, setSelectedPeriodKey] = React.useState('monthly')
const [selectedPriceId, setSelectedPriceId] = React.useState(null) const [selectedPriceId, setSelectedPriceId] = React.useState(null)
const [processingPriceId, setProcessingPriceId] = React.useState(null) const [processingPriceId, setProcessingPriceId] = React.useState(null)
const [errorMessage, setErrorMessage] = React.useState(null) const [errorMessage, setErrorMessage] = React.useState(null)
const initialPackHandledRef = React.useRef(false) const initialPackHandledRef = React.useRef(false)
const registrationCompletedRef = React.useRef(false)
const normalizedPlansByPeriod = React.useMemo( const normalizedPlansByPeriod = React.useMemo(
() => ({ () => ({
@@ -344,7 +351,43 @@ export default function Subscriptions() {
const hasAnyPlan = availablePeriods.length > 0 const hasAnyPlan = availablePeriods.length > 0
const isLoadingPlans = isCatalogLoading && !hasAnyPlan const isLoadingPlans = isCatalogLoading && !hasAnyPlan
const combinedErrorMessage = errorMessage || catalogError const combinedErrorMessage = errorMessage || catalogError
const screenTitle = isCoinPackView ? COIN_PACK_SECTION_TITLE : 'Rejoignez le club MusicLand' const screenTitle = isCoinPackView
? COIN_PACK_SECTION_TITLE
: isRegistrationFlow
? 'Choisis ton accès MusicLand'
: 'Rejoignez le club MusicLand'
const finishRegistrationFlow = React.useCallback(async () => {
if (
!isRegistrationFlow ||
registrationCompletedRef.current ||
(registrationChoice !== 'listen' && registrationChoice !== 'creation')
) {
return
}
registrationCompletedRef.current = true
try {
await completeRegistrationFlow(registrationChoice)
reset(getExperienceReset(registrationChoice))
} catch (error) {
registrationCompletedRef.current = false
setErrorMessage(error?.message || 'Impossible de terminer ton inscription.')
}
}, [isRegistrationFlow, registrationChoice])
React.useEffect(() => {
if (isRegistrationFlow && hasActiveSubscription) {
finishRegistrationFlow()
}
}, [finishRegistrationFlow, hasActiveSubscription, isRegistrationFlow])
const handleSkipRegistrationPayment = React.useCallback(() => {
if (!canSkipRegistrationPayment) {
return
}
finishRegistrationFlow()
}, [canSkipRegistrationPayment, finishRegistrationFlow])
React.useEffect(() => { React.useEffect(() => {
initialPackHandledRef.current = false initialPackHandledRef.current = false
@@ -721,9 +764,23 @@ export default function Subscriptions() {
disabled={isActionDisabled} disabled={isActionDisabled}
gradientStyle={[styles.actionButtonGradient, styles.mobileActionButtonGradient]} gradientStyle={[styles.actionButtonGradient, styles.mobileActionButtonGradient]}
/> />
{canSkipRegistrationPayment ? (
<BorderGradientButton
title="Continuer avec mes 10 crédits"
onPress={handleSkipRegistrationPayment}
containerStyle={styles.skipPaymentButton}
/>
) : null}
</View> </View>
) )
}, [actionButtonTitle, handleCheckout, isActionDisabled, mobileActionSafePadding]) }, [
actionButtonTitle,
canSkipRegistrationPayment,
handleCheckout,
handleSkipRegistrationPayment,
isActionDisabled,
mobileActionSafePadding,
])
const renderWebBackButton = React.useCallback(() => { const renderWebBackButton = React.useCallback(() => {
if (isMobile) return null if (isMobile) return null
@@ -803,7 +860,7 @@ export default function Subscriptions() {
))} ))}
</View> </View>
{coinPacks && coinPacks.length > 0 ? ( {!isRegistrationFlow && coinPacks && coinPacks.length > 0 ? (
<View style={styles.creditsSection}> <View style={styles.creditsSection}>
<Text style={styles.sectionTitle}>{COIN_PACK_SECTION_TITLE}</Text> <Text style={styles.sectionTitle}>{COIN_PACK_SECTION_TITLE}</Text>
<View style={styles.creditsGrid}> <View style={styles.creditsGrid}>
@@ -871,6 +928,13 @@ export default function Subscriptions() {
disabled={isActionDisabled} disabled={isActionDisabled}
gradientStyle={styles.actionButtonGradient} gradientStyle={styles.actionButtonGradient}
/> />
{canSkipRegistrationPayment ? (
<BorderGradientButton
title="Continuer avec mes 10 crédits"
onPress={handleSkipRegistrationPayment}
containerStyle={styles.skipPaymentButton}
/>
) : null}
</View> </View>
<Text style={styles.disclaimer}>{SUBSCRIPTION_DISCLAIMER}</Text> <Text style={styles.disclaimer}>{SUBSCRIPTION_DISCLAIMER}</Text>
@@ -1440,6 +1504,9 @@ const styles = StyleSheet.create({
shadowRadius: 12, shadowRadius: 12,
elevation: 12, elevation: 12,
}, },
skipPaymentButton: {
width: '100%',
},
mobileActionButtonGradient: { mobileActionButtonGradient: {
width: '100%', width: '100%',
}, },
+10 -2
View File
@@ -3,6 +3,7 @@ import { Dimensions, KeyboardAvoidingView, Platform, View } from 'react-native'
import { responsiveHeight } from 'react-native-responsive-dimensions' import { responsiveHeight } from 'react-native-responsive-dimensions'
import { SwiperFlatList } from 'react-native-swiper-flatlist' import { SwiperFlatList } from 'react-native-swiper-flatlist'
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'
@@ -26,7 +27,7 @@ const CUSTOM_SANITIZE_OPTIONS = { autoInjectPreChorus: false }
const CTA_BUTTON_MAX_WIDTH = 500 const CTA_BUTTON_MAX_WIDTH = 500
const CreateLyricsWithAi = () => { const CreateLyricsWithAi = () => {
const { selectedProject } = useUser() const { selectedProject, videos } = useUser()
const scrollRef = useRef(null) const scrollRef = useRef(null)
const [selectedIndex, setSelectedIndex] = useState(0) const [selectedIndex, setSelectedIndex] = useState(0)
const [progress, setProgress] = useState(16) const [progress, setProgress] = useState(16)
@@ -341,9 +342,16 @@ const CreateLyricsWithAi = () => {
}, [selectedIndex, parentLayout?.height]) }, [selectedIndex, parentLayout?.height])
const isCtaVisible = selectedIndex !== 8 const isCtaVisible = selectedIndex !== 8
const isQuestionStep = selectedIndex < MAX_STEP_INDEX
return ( return (
<Page headerType="NONE" backgroundImg={background.writingMobileBackground}> <Page
headerType="NONE"
backgroundImg={isQuestionStep ? null : background.writingMobileBackground}
backgroundContent={
isQuestionStep ? <BackgroundVideo source={videos?.celine} /> : null
}
>
<View <View
onLayout={(event) => { onLayout={(event) => {
const nextHeight = event?.nativeEvent?.layout?.height const nextHeight = event?.nativeEvent?.layout?.height
+16 -3
View File
@@ -1,7 +1,8 @@
import React, { useMemo, useState } from 'react' import React, { useMemo, useState } from 'react'
import { Platform, View } from 'react-native' import { Platform, StyleSheet, 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'
@@ -24,7 +25,7 @@ const MAX_STEP_INDEX = 8
const CUSTOM_SANITIZE_OPTIONS = { autoInjectPreChorus: false } const CUSTOM_SANITIZE_OPTIONS = { autoInjectPreChorus: false }
const CreateLyricsWithAi = () => { const CreateLyricsWithAi = () => {
const { selectedProject } = useUser() const { selectedProject, videos } = useUser()
const [selectedIndex, setSelectedIndex] = useState(0) const [selectedIndex, setSelectedIndex] = useState(0)
const handleLyricsError = React.useCallback(() => { const handleLyricsError = React.useCallback(() => {
setSelectedIndex(7) setSelectedIndex(7)
@@ -385,13 +386,18 @@ 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={background.libraryBgWeb} backgroundImg={isQuestionStep ? null : 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
@@ -429,3 +435,10 @@ const CreateLyricsWithAi = () => {
} }
export default CreateLyricsWithAi export default CreateLyricsWithAi
const styles = StyleSheet.create({
videoQuestionPage: {
alignSelf: 'flex-end',
marginRight: '4%',
},
})
+1 -1
View File
@@ -328,7 +328,7 @@ const Lyrics = ({ navigation }) => {
return return
} }
const handleNavigateToStudio = () => { const handleNavigateToStudio = () => {
const targetRoute = beatmakerStage?.route || Routes.Compose const targetRoute = beatmakerStage?.route || Routes.ComposeSong
navigate(targetRoute, { navigate(targetRoute, {
...(beatmakerStage?.params || {}), ...(beatmakerStage?.params || {}),
fromLyrics: true, fromLyrics: true,
+4 -20
View File
@@ -3,7 +3,6 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Modal, Platform, Pressable, StyleSheet, Text, View } from 'react-native' import { Modal, Platform, Pressable, StyleSheet, Text, View } from 'react-native'
import useMinuit from 'react-native-minuit/src/hooks/useMinuit' import useMinuit from 'react-native-minuit/src/hooks/useMinuit'
import BorderGradientButton from '../../components/BorderGradientButton' import BorderGradientButton from '../../components/BorderGradientButton'
import FullscreenIntroVideo from '../../components/FullscreenIntroVideo'
import GradientButton from '../../components/GradientButton' import GradientButton from '../../components/GradientButton'
import { Input } from '../../components/Input' import { Input } from '../../components/Input'
import MusicLandHeader from '../../components/MusicLandHeader' import MusicLandHeader from '../../components/MusicLandHeader'
@@ -19,7 +18,6 @@ import { background } from '../../assets'
export const ChooseCoverType = () => { export const ChooseCoverType = () => {
const isIOS = Platform.OS === 'ios' const isIOS = Platform.OS === 'ios'
const [showIntro, setShowIntro] = useState(true)
const [choiceVisible, setChoiceVisible] = useState(false) const [choiceVisible, setChoiceVisible] = useState(false)
const [pseudoVisible, setPseudoVisible] = useState(false) const [pseudoVisible, setPseudoVisible] = useState(false)
const [choiceDismissAction, setChoiceDismissAction] = useState(null) const [choiceDismissAction, setChoiceDismissAction] = useState(null)
@@ -33,12 +31,6 @@ export const ChooseCoverType = () => {
const { setTooltip } = useMinuit() const { setTooltip } = useMinuit()
const projectId = selectedProject?.id || selectedProjectId || null const projectId = selectedProject?.id || selectedProjectId || null
const hasFinalCover = !!selectedProject?.coverUrl
const hasGeneratedOptions = Array.isArray(selectedProject?.cover?.options)
? selectedProject.cover.options.length > 0
: false
const generateCoverLabel = hasFinalCover ? 'Modifier la pochette' : 'Générer la pochette'
const hasArtistPreference = useMemo(() => { const hasArtistPreference = useMemo(() => {
if (currentUserData?.userName) return true if (currentUserData?.userName) return true
const pref = currentUserData?.artistNamePreference const pref = currentUserData?.artistNamePreference
@@ -108,13 +100,9 @@ export const ChooseCoverType = () => {
[currentUID, projectId] [currentUID, projectId]
) )
const handleGenerateCover = useCallback(() => { const handleShowCover = useCallback(() => {
if (hasFinalCover || hasGeneratedOptions) {
navigate(Routes.ValidateCover)
return
}
ensureArtistPreference(() => navigate(Routes.PouchReady)) ensureArtistPreference(() => navigate(Routes.PouchReady))
}, [ensureArtistPreference, hasFinalCover, hasGeneratedOptions]) }, [ensureArtistPreference])
const closeChoiceModal = useCallback(() => { const closeChoiceModal = useCallback(() => {
setChoiceVisible(false) setChoiceVisible(false)
@@ -276,15 +264,11 @@ export const ChooseCoverType = () => {
onPress={handlePickUserImage} onPress={handlePickUserImage}
/> */} /> */}
<GradientButton <GradientButton
title={generateCoverLabel} title="Voir ma pochette"
onPress={handleGenerateCover} onPress={handleShowCover}
/> />
</View> </View>
</View> </View>
{/*<FullscreenIntroVideo*/}
{/* visible={showIntro}*/}
{/* onClose={() => setShowIntro(false)}*/}
{/*/>*/}
<Modal <Modal
visible={choiceVisible} visible={choiceVisible}
transparent transparent
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,3 @@
export const createMusiclandPlaybackCover = async () => {
throw new Error('cover_client_rendering_is_web_only')
}
@@ -0,0 +1,108 @@
import { Asset } from 'expo-asset'
import { img } from '../assets'
const COVER_SIZE = 1024
const PROFILE_SIZE = 228
const loadImage = (source) =>
new Promise((resolve, reject) => {
const image = new window.Image()
image.crossOrigin = 'anonymous'
image.onload = () => resolve(image)
image.onerror = () => reject(new Error('cover_image_load_failed'))
image.src = source
})
const drawCenteredText = (context, text, y, fontSize, letterSpacing) => {
const characters = Array.from(text)
const charactersWidth = characters.reduce(
(width, character) => width + context.measureText(character).width,
0
)
const totalWidth = charactersWidth + Math.max(characters.length - 1, 0) * letterSpacing
let x = (COVER_SIZE - totalWidth) / 2
characters.forEach((character) => {
context.fillText(character, x, y)
x += context.measureText(character).width + letterSpacing
})
}
const getArtistFontSize = (artistName) => {
if (artistName.length <= 16) return 52
if (artistName.length <= 24) return 44
return 36
}
const drawProfilePicture = (context, profilePicture) => {
const sourceRatio = profilePicture.width / profilePicture.height
let sourceX = 0
let sourceY = 0
let sourceWidth = profilePicture.width
let sourceHeight = profilePicture.height
if (sourceRatio > 1) {
sourceWidth = profilePicture.height
sourceX = (profilePicture.width - sourceWidth) / 2
} else {
sourceHeight = profilePicture.width
sourceY = (profilePicture.height - sourceHeight) / 2
}
context.save()
context.beginPath()
context.arc(512, 568, PROFILE_SIZE / 2, 0, Math.PI * 2)
context.clip()
context.drawImage(
profilePicture,
sourceX,
sourceY,
sourceWidth,
sourceHeight,
398,
454,
PROFILE_SIZE,
PROFILE_SIZE
)
context.restore()
}
export const createMusiclandPlaybackCover = async ({ artistName, profilePictureURL }) => {
const templateAsset = Asset.fromModule(img.musiclandPlaybackCover)
await templateAsset.downloadAsync()
const assetUri = templateAsset.localUri || templateAsset.uri
if (!assetUri) {
throw new Error('cover_template_missing')
}
const [template, profilePicture] = await Promise.all([
loadImage(assetUri),
profilePictureURL ? loadImage(profilePictureURL) : Promise.resolve(null),
])
const canvas = document.createElement('canvas')
canvas.width = COVER_SIZE
canvas.height = COVER_SIZE
const context = canvas.getContext('2d')
context.drawImage(template, 0, 0, COVER_SIZE, COVER_SIZE)
if (profilePicture) {
drawProfilePicture(context, profilePicture)
}
context.fillStyle = '#FFFFFF'
context.textBaseline = 'middle'
context.font = `400 ${getArtistFontSize(artistName)}px Inter, Arial, sans-serif`
drawCenteredText(context, artistName, 824, getArtistFontSize(artistName), 3)
context.font = '400 54px Inter, Arial, sans-serif'
drawCenteredText(context, 'PLAYBACKER', 963, 54, 5)
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) {
resolve(blob)
return
}
reject(new Error('cover_export_failed'))
}, 'image/png')
})
}
+6 -15
View File
@@ -136,7 +136,7 @@ const getStageDescription = (key, metadata) => {
return 'Commencer la création de votre morceau' return 'Commencer la création de votre morceau'
case 'director': case 'director':
if (!hasCover) { if (!hasCover) {
return 'Générez une cover pour débloquer le playback' return 'Validez votre pochette pour débloquer le playback'
} }
if (isPlaybackGenerating) { if (isPlaybackGenerating) {
return 'Votre playback est en cours de préparation' return 'Votre playback est en cours de préparation'
@@ -222,18 +222,17 @@ export const getStageAction = (key, project) => {
switch (key) { switch (key) {
case 'songwriter': case 'songwriter':
return { return {
route: getLyricsCount(project) > 0 ? Routes.Lyrics : Routes.WritingLyrics, route: getLyricsCount(project) > 0 ? Routes.Lyrics : Routes.CreateLyricsWithAi,
} }
case 'beatmaker': { case 'beatmaker': {
if (!project) { if (!project) {
return { route: Routes.Compose } return { route: Routes.ComposeSong }
} }
const musicStatus = project?.musicStatus || null const musicStatus = project?.musicStatus || null
const songUrl = project?.songUrl || null const songUrl = project?.songUrl || null
const coverStatus = project?.coverStatus || null const coverStatus = project?.coverStatus || null
const cover = project?.cover || {} const cover = project?.cover || {}
const hasCoverBackground = !!cover?.generatedBackground
const hasCoverResult = !!cover?.result const hasCoverResult = !!cover?.result
const hasFinalCover = !!project?.coverUrl const hasFinalCover = !!project?.coverUrl
@@ -245,26 +244,18 @@ export const getStageAction = (key, project) => {
if (musicStatus === 'GENERATED') { if (musicStatus === 'GENERATED') {
return { route: Routes.SongReady } return { route: Routes.SongReady }
} }
return { route: Routes.Compose } return { route: Routes.ComposeSong }
} }
if (coverStatus === 'GENERATING') { if (coverStatus === 'GENERATING') {
return { route: Routes.PouchReady } return { route: Routes.PouchReady }
} }
if (!hasCoverBackground) { if (!hasCoverResult) {
return { route: Routes.ChooseCoverType } return { route: Routes.ChooseCoverType }
} }
if (!hasCoverResult) { return { route: hasFinalCover ? Routes.ChooseCoverType : Routes.PouchReady }
return { route: Routes.PouchReady }
}
if (!hasFinalCover) {
return { route: Routes.ValidateCover }
}
return { route: Routes.ChooseCoverType }
} }
case 'director': case 'director':
return { return {
+73
View File
@@ -0,0 +1,73 @@
import firebase, { usersRef } from '../config/firebase'
import { Routes } from '../navigation/Routes'
export const MUSICLAND_LAUNCH_DATE = null
export const isGlobalListeningLaunchPeriodActive = (now = new Date()) => {
if (!MUSICLAND_LAUNCH_DATE) {
return false
}
const launchDate = new Date(MUSICLAND_LAUNCH_DATE)
if (Number.isNaN(launchDate.getTime())) {
return false
}
const launchPeriodEnd = new Date(launchDate)
launchPeriodEnd.setMonth(launchPeriodEnd.getMonth() + 1)
return now >= launchDate && now < launchPeriodEnd
}
export const getRouteAfterAuthentication = (userData = {}) =>
userData?.registrationFlowRequired === true
? Routes.RegistrationChoice
: Routes.BottomTab
export const getExperienceReset = (experience) => {
if (experience === 'listen') {
return {
index: 0,
routes: [
{
name: Routes.BottomTab,
params: { screen: Routes.HitParade },
},
],
}
}
return {
index: 0,
routes: [
{
name: Routes.BottomTab,
params: {
screen: Routes.HomeStack,
params: { screen: Routes.Home },
},
},
],
}
}
export const completeRegistrationFlow = async (experience) => {
const uid = firebase.auth().currentUser?.uid
if (!uid) {
throw new Error('Aucun utilisateur connecté')
}
if (experience !== 'listen' && experience !== 'creation') {
throw new Error('Expérience MusicLand inconnue')
}
await usersRef.doc(uid).set(
{
registrationFlowRequired: false,
initialExperience: experience,
registrationFlowCompletedAt: firebase.firestore.FieldValue.serverTimestamp(),
updatedAt: firebase.firestore.FieldValue.serverTimestamp(),
},
{ merge: true }
)
}