feat: refine creation and registration flows
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 1.8 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 864 KiB |
@@ -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 |
+175
-247
@@ -5,170 +5,152 @@ 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
String(value)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
|
||||||
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()
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('[Cover] Unable to load profile image, keeping default icon', {
|
||||||
|
error: error.message,
|
||||||
|
})
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Upload vers Storage
|
async function uploadCover(buffer, targetPath) {
|
||||||
const token = crypto.randomUUID()
|
const token = crypto.randomUUID()
|
||||||
const file = bucket.file(targetPath)
|
const file = bucket.file(targetPath)
|
||||||
|
|
||||||
await file.save(stampedBuffer, {
|
await file.save(buffer, {
|
||||||
resumable: false,
|
resumable: false,
|
||||||
metadata: {
|
metadata: {
|
||||||
contentType: 'image/png',
|
contentType: 'image/png',
|
||||||
@@ -178,88 +160,46 @@ async function buildCoverWithBrandText(backgroundUrl, targetPath) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(targetPath)}?alt=media&token=${token}`
|
return `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(targetPath)}?alt=media&token=${token}`
|
||||||
} catch (error) {
|
|
||||||
logger.error('❌ [Cover] buildCoverWithBrandText failed', error)
|
|
||||||
// En cas d'échec du texte, on renvoie l'URL originale pour ne pas tout perdre
|
|
||||||
return backgroundUrl
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async function prepareMusiclandCover(project) {
|
||||||
* Cœur de la logique de génération
|
const startedAt = Date.now()
|
||||||
*/
|
const userId = typeof project.userId === 'string' ? project.userId.trim() : ''
|
||||||
async function performCoverGeneration(project) {
|
if (!userId) {
|
||||||
const t0 = Date.now()
|
throw new Error('Utilisateur du projet introuvable')
|
||||||
const artistName = await resolveArtistName(project)
|
|
||||||
|
|
||||||
const baseTimestamp = Date.now()
|
|
||||||
const GENERATION_COUNT = 2 // Nombre de variantes simultanées
|
|
||||||
|
|
||||||
// Création d'un tableau de promesses pour exécuter les tâches en parallèle
|
|
||||||
const generationPromises = Array.from({ length: GENERATION_COUNT }).map(async (_, index) => {
|
|
||||||
const uniqueSuffix = `${baseTimestamp}-${index}`
|
|
||||||
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
|
|
||||||
const results = await Promise.all(generationPromises)
|
|
||||||
|
|
||||||
// On garde uniquement les tentatives réussies (non null)
|
|
||||||
const options = results.filter(Boolean)
|
|
||||||
|
|
||||||
if (options.length === 0) {
|
|
||||||
throw new Error('Toutes les tentatives de génération ont échoué.')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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,40 +207,28 @@ 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 })
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 1. Validation & Setup
|
|
||||||
if (type === 'combine') {
|
if (type === 'combine') {
|
||||||
// Feature désactivée pour le moment
|
|
||||||
await event.data.ref.update({
|
await event.data.ref.update({
|
||||||
status: 'CANCELLED',
|
status: 'CANCELLED',
|
||||||
error: "La personnalisation photo n'est plus disponible.",
|
error: "La personnalisation photo n'est plus disponible.",
|
||||||
@@ -309,40 +237,38 @@ exports.onTaskCreateGenerateCover = onDocumentCreated(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mise à jour statut projet
|
logger.info(`[Task ${taskId}] MusicLand cover started`, { projectId })
|
||||||
|
|
||||||
|
try {
|
||||||
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 |
@@ -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 = {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-11
@@ -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(
|
||||||
|
userCredential?.user,
|
||||||
|
{
|
||||||
firstName,
|
firstName,
|
||||||
lastName,
|
lastName,
|
||||||
displayName:
|
displayName:
|
||||||
userCredential?.user?.displayName ||
|
userCredential?.user?.displayName ||
|
||||||
[firstName, lastName].filter(Boolean).join(' ') ||
|
[firstName, lastName].filter(Boolean).join(' ') ||
|
||||||
null,
|
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
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 () => {}
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,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 {
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 d’ouvrir l’espace É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 l’espace 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
|
||||||
@@ -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),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|||||||
@@ -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),
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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%',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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%',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+193
-851
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')
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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,27 +244,19 @@ 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) {
|
|
||||||
return { route: Routes.ChooseCoverType }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasCoverResult) {
|
if (!hasCoverResult) {
|
||||||
return { route: Routes.PouchReady }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasFinalCover) {
|
|
||||||
return { route: Routes.ValidateCover }
|
|
||||||
}
|
|
||||||
|
|
||||||
return { route: Routes.ChooseCoverType }
|
return { route: Routes.ChooseCoverType }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return { route: hasFinalCover ? Routes.ChooseCoverType : Routes.PouchReady }
|
||||||
|
}
|
||||||
case 'director':
|
case 'director':
|
||||||
return {
|
return {
|
||||||
route: Routes.Playback,
|
route: Routes.Playback,
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user