diff --git a/design-qa-assets/musicland-cover-comparison.png b/design-qa-assets/musicland-cover-comparison.png new file mode 100644 index 0000000..04028f6 Binary files /dev/null and b/design-qa-assets/musicland-cover-comparison.png differ diff --git a/design-qa-assets/musicland-cover-preview.png b/design-qa-assets/musicland-cover-preview.png new file mode 100644 index 0000000..b665e25 Binary files /dev/null and b/design-qa-assets/musicland-cover-preview.png differ diff --git a/design-qa.md b/design-qa.md new file mode 100644 index 0000000..3a33d1d --- /dev/null +++ b/design-qa.md @@ -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 diff --git a/functions/assets/musiclandPlaybackCover.png b/functions/assets/musiclandPlaybackCover.png new file mode 100644 index 0000000..a9ac203 Binary files /dev/null and b/functions/assets/musiclandPlaybackCover.png differ diff --git a/functions/src/cover.js b/functions/src/cover.js index 572b10f..b6b12d5 100644 --- a/functions/src/cover.js +++ b/functions/src/cover.js @@ -5,261 +5,201 @@ const logger = require('firebase-functions/logger') const axios = require('axios') const sharp = require('sharp') 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 { sendNotification } = require('./notifications') -// Configuration const bucket = admin.storage().bucket() -const BRAND_TEXT = 'By Musicland.ai' -const BRAND_FONT_FAMILY = 'Poppins, Montserrat, Arial, sans-serif' -const BRAND_BG_COLOR = 'rgba(0,0,0,0.65)' +const COVER_TEMPLATE = 'MUSICLAND_PLAYBACKER' +const COVER_OPTION_ID = 'musicland-playbacker' +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 = '') => - String(value).replace(/&/g, '&').replace(//g, '>') + String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') -const buildBrandSvg = ({ width = 1024, height = 1024, text = BRAND_TEXT } = {}) => { - const safeWidth = Math.max(320, Math.round(width)) - const safeHeight = Math.max(320, Math.round(height)) - const margin = Math.round(safeWidth * 0.04) - const fontSize = Math.max(22, Math.round(safeWidth * 0.045)) - const letterSpacing = Math.max(1, Math.round(fontSize * 0.06)) - 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 paddingX = Math.max(10, Math.round(fontSize * 0.7)) - const paddingY = Math.max(6, Math.round(fontSize * 0.4)) - const approxCharWidth = fontSize * 0.62 - const safeText = escapeSvgText(text) - const textLength = safeText.length - const textWidth = - approxCharWidth * textLength + letterSpacing * Math.max(0, textLength - 1) - const rectWidth = Math.max(0, Math.round(textWidth + paddingX * 2)) - 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)) +const normalizeArtistName = (value) => { + if (typeof value !== 'string') { + return 'MusicLand' + } + const trimmedValue = value.trim() + return trimmedValue || 'MusicLand' +} + +const getArtistFontSize = (artistName) => { + if (artistName.length <= 16) return 52 + if (artistName.length <= 24) return 44 + return 36 +} + +const buildTextOverlay = (artistName) => { + const safeArtistName = escapeSvgText(artistName) + const artistFontSize = getArtistFontSize(artistName) return Buffer.from(` - - - - - - - + ${safeText} + dominant-baseline="middle" + font-family="${FONT_FAMILY}" + font-size="${artistFontSize}" + font-weight="400" + letter-spacing="3" + fill="#FFFFFF" + >${safeArtistName} + PLAYBACKER `) } -/** - * Utilitaires Strings - */ -const pickFirstNonEmpty = (...values) => { - for (const value of values) { - if (typeof value === 'string' && value.trim().length > 0) { - return value.trim() +const buildCircularMask = (size) => { + const mask = Buffer.alloc(size * size * 4) + const radius = size / 2 + const center = radius - 0.5 + + for (let y = 0; y < size; y += 1) { + 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) => - parts - .map((part) => (typeof part === 'string' ? part.trim() : '')) - .filter(Boolean) - .join(' ') - .trim() +async function loadCoverIdentity(project) { + const artistName = normalizeArtistName(project.userName) + const userId = typeof project.userId === 'string' ? project.userId.trim() : '' -/** - * Résolution intelligente du nom d'artiste - */ -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 '' + if (!userId) { + return { artistName, profilePictureURL: null } + } try { const userSnapshot = await refList.users.doc(userId).get() - if (!userSnapshot?.exists) return '' - - const userData = userSnapshot.data() || {} - return ( - pickFirstNonEmpty( - userData.artistName, - userData.userName, - userData.displayName, - combineNames(userData.firstName, userData.lastName) - ) || '' - ) + const userData = userSnapshot.exists ? userSnapshot.data() : null + return { + artistName: normalizeArtistName(project.userName || userData?.userName), + profilePictureURL: userData?.profilePictureURL || null, + } } catch (error) { - logger.warn('⚠️ [Cover] Artist name resolution failed', { - projectId: project?.id, + logger.warn('[Cover] Unable to load profile picture', { + projectId: project.id, error: error.message, }) - return '' + return { artistName, profilePictureURL: null } } } -/** - * Ajoute le texte de marque en filigrane sur l'image générée - */ -async function buildCoverWithBrandText(backgroundUrl, targetPath) { - logger.info('🖼️ [Cover] Compositing brand text...') +async function buildProfileOverlay(profilePictureURL) { + if (!profilePictureURL) { + return null + } try { - // Téléchargement background - const bgResponse = await axios.get(backgroundUrl, { responseType: 'arraybuffer' }) - - const baseImage = sharp(bgResponse.data) - 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 + const response = await axios.get(profilePictureURL, { responseType: 'arraybuffer' }) + const circularMask = buildCircularMask(PROFILE_SIZE) + return await sharp(response.data) + .resize(PROFILE_SIZE, PROFILE_SIZE, { fit: 'cover', position: 'centre' }) .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() .toBuffer() - - // Upload vers Storage - const token = crypto.randomUUID() - const file = bucket.file(targetPath) - - await file.save(stampedBuffer, { - resumable: false, - metadata: { - contentType: 'image/png', - cacheControl: 'public, max-age=31536000', - metadata: { firebaseStorageDownloadTokens: token }, - }, - }) - - return `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(targetPath)}?alt=media&token=${token}` } catch (error) { - logger.error('❌ [Cover] buildCoverWithBrandText failed', error) - // En cas d'échec du texte, on renvoie l'URL originale pour ne pas tout perdre - return backgroundUrl + logger.warn('[Cover] Unable to load profile image, keeping default icon', { + error: error.message, + }) + return null } } -/** - * Cœur de la logique de génération - */ -async function performCoverGeneration(project) { - const t0 = Date.now() - const artistName = await resolveArtistName(project) +async function uploadCover(buffer, targetPath) { + const token = crypto.randomUUID() + const file = bucket.file(targetPath) - 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 - } + await file.save(buffer, { + resumable: false, + metadata: { + contentType: 'image/png', + cacheControl: 'public, max-age=31536000', + metadata: { firebaseStorageDownloadTokens: token }, + }, }) - // Attente de la résolution de toutes les générations - const results = await Promise.all(generationPromises) + return `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/${encodeURIComponent(targetPath)}?alt=media&token=${token}` +} - // 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é.') +async function prepareMusiclandCover(project) { + const startedAt = Date.now() + const userId = typeof project.userId === 'string' ? project.userId.trim() : '' + if (!userId) { + throw new Error('Utilisateur du projet introuvable') } - // Sauvegarde dans Firestore - const [firstOption] = options + const { artistName, profilePictureURL } = await loadCoverIdentity(project) + 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( { cover: { - generatedBackground: firstOption.generatedUrl, - result: firstOption.finalUrl, - // selectedOptionId: firstOption.id, // disable default selection - options, // Sauvegarde de toutes les variantes réussies + template: COVER_TEMPLATE, + result: coverUrl, + generatedBackground: null, + selectedOptionId: COVER_OPTION_ID, + options: [option], }, coverStatus: 'GENERATED', updatedAt: FieldValue.serverTimestamp(), @@ -267,82 +207,68 @@ async function performCoverGeneration(project) { { merge: true } ) - logger.info('🏁 [Cover] Process complete', { + logger.info('[Cover] MusicLand cover prepared', { projectId: project.id, - successCount: options.length, - duration: Date.now() - t0, + duration: Date.now() - startedAt, }) - return firstOption.finalUrl + return coverUrl } -/** - * TRIGGER FIRESTORE - * Déclenché à la création d'un document dans 'tasks/{taskId}' - */ exports.onTaskCreateGenerateCover = onDocumentCreated( { - timeoutSeconds: 540, // 9 minutes max (Imagen peut être lent) + timeoutSeconds: 120, memory: '1GiB', document: 'tasks/{taskId}', - secrets: [GEMINI_API_KEY], }, async (event) => { const data = event.data?.data() || {} const { type, projectId } = data const taskId = event.params.taskId - if (!projectId) return // Ignorer les tâches mal formées - if (!['cover', 'combine'].includes(type)) return // Ignorer les autres types de tâches + if (!projectId || !['cover', 'combine'].includes(type)) return - logger.info(`🚀 [Task ${taskId}] Started`, { type, projectId }) + if (type === 'combine') { + await event.data.ref.update({ + status: 'CANCELLED', + error: "La personnalisation photo n'est plus disponible.", + updatedAt: FieldValue.serverTimestamp(), + }) + return + } + + logger.info(`[Task ${taskId}] MusicLand cover started`, { projectId }) try { - // 1. Validation & Setup - if (type === 'combine') { - // Feature désactivée pour le moment - await event.data.ref.update({ - status: 'CANCELLED', - error: "La personnalisation photo n'est plus disponible.", - updatedAt: FieldValue.serverTimestamp(), - }) - return - } - - // Mise à jour statut projet await refList.projects.doc(projectId).update({ coverStatus: 'GENERATING', updatedAt: FieldValue.serverTimestamp(), }) - // 2. Chargement Projet - const projectSnap = await refList.projects.doc(projectId).get() - if (!projectSnap.exists) 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 + const projectSnapshot = await refList.projects.doc(projectId).get() + if (!projectSnapshot.exists) { + throw new Error('Projet introuvable') } - // 3. Exécution Génération - const coverUrl = await performCoverGeneration(project) + const project = { id: projectId, ...projectSnapshot.data() } + 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({ status: 'DONE', coverUrl, updatedAt: FieldValue.serverTimestamp(), }) - // 5. Notification if (project.userId) { const projectTitle = project.title || 'ton projet' await sendNotification({ @@ -350,28 +276,30 @@ exports.onTaskCreateGenerateCover = onDocumentCreated( receiver: project.userId, receiverCollection: 'users', 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: { type: ALERT_TYPE?.COVER_GENERATION_SUCCESS, projectId, projectTitle, coverUrl, }, - }).catch((err) => logger.warn('Notification failed', err)) + }).catch((error) => logger.warn('Notification failed', error)) } } catch (error) { - logger.error(`🔥 [Task ${taskId}] Failed`, error) - - // Mise à jour erreur Tâche - await event.data.ref.set({ status: 'ERROR', error: error.message }, { merge: true }) - - // Mise à jour erreur Projet + logger.error(`[Task ${taskId}] MusicLand cover failed`, error) + await event.data.ref.set( + { + status: 'ERROR', + error: error.message, + updatedAt: FieldValue.serverTimestamp(), + }, + { merge: true } + ) await refList.projects.doc(projectId).update({ coverStatus: 'ERROR', updatedAt: FieldValue.serverTimestamp(), }) - // Notification Erreur const projectData = (await refList.projects.doc(projectId).get()).data() if (projectData?.userId) { await sendNotification({ @@ -379,7 +307,7 @@ exports.onTaskCreateGenerateCover = onDocumentCreated( receiver: projectData.userId, receiverCollection: 'users', 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: { type: ALERT_TYPE?.COVER_GENERATION_FAILED, projectId, diff --git a/src/assets/UI/musiclandPlaybackCover.png b/src/assets/UI/musiclandPlaybackCover.png new file mode 100644 index 0000000..a9ac203 Binary files /dev/null and b/src/assets/UI/musiclandPlaybackCover.png differ diff --git a/src/assets/index.js b/src/assets/index.js index 9b752be..04ed012 100644 --- a/src/assets/index.js +++ b/src/assets/index.js @@ -105,6 +105,7 @@ import placeholder3 from './UI/placeholder3.png' import placeholder4 from './UI/placeholder4.jpg' import profile from './UI/profile.jpg' import musiclandClub from './UI/musiclandClub.png' +import musiclandPlaybackCover from './UI/musiclandPlaybackCover.png' import frenchFlag from './icons/frenchFlag.png' import englishFlag from './icons/englishFlag.png' @@ -259,6 +260,7 @@ export const img = { profile, goodVibe, musiclandClub, + musiclandPlaybackCover, } export const cardsImg = { @@ -278,4 +280,4 @@ export const planBadges = { starter: require('./icons/premiumBadge.png'), pro: require('./icons/starterBadge.png'), premium: require('./icons/proBadge.png'), -} \ No newline at end of file +} diff --git a/src/components/BackgroundVideo.native.js b/src/components/BackgroundVideo.native.js new file mode 100644 index 0000000..ebe0bea --- /dev/null +++ b/src/components/BackgroundVideo.native.js @@ -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 ( + + + + setMuted((currentMuted) => !currentMuted)} + style={({ pressed }) => [styles.soundButton, pressed && styles.soundButtonPressed]} + > + + {muted ? 'Activer le son' : 'Couper le son'} + + + ) +} + +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', + }, +}) diff --git a/src/components/BackgroundVideo.web.js b/src/components/BackgroundVideo.web.js new file mode 100644 index 0000000..acc0226 --- /dev/null +++ b/src/components/BackgroundVideo.web.js @@ -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 ( + + + + setMuted((currentMuted) => !currentMuted)} + style={({ hovered, pressed }) => [ + styles.soundButton, + soundButtonStyle, + hovered && styles.soundButtonHovered, + pressed && styles.soundButtonPressed, + ]} + > + + {muted ? 'Activer le son' : 'Couper le son'} + + + ) +} + +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', + }, +}) diff --git a/src/components/MusiclandPlaybackCover.js b/src/components/MusiclandPlaybackCover.js new file mode 100644 index 0000000..f7d10cc --- /dev/null +++ b/src/components/MusiclandPlaybackCover.js @@ -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 ( + + + {!coverUrl && profilePictureURL ? ( + + ) : null} + {!coverUrl ? ( + <> + + {displayName} + + PLAYBACKER + + ) : null} + + ) +} + +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) diff --git a/src/hooks/useNavigateToMusicDetails.js b/src/hooks/useNavigateToMusicDetails.js index 46c7f69..bf431dd 100644 --- a/src/hooks/useNavigateToMusicDetails.js +++ b/src/hooks/useNavigateToMusicDetails.js @@ -33,7 +33,7 @@ const useNavigateToMusicDetails = () => { if (resolvedProject && isOwner) { selectProject?.(projectId) const stageAction = getStageAction('beatmaker', resolvedProject) - const targetRoute = stageAction?.route || Routes.Compose + const targetRoute = stageAction?.route || Routes.ComposeSong navigate(targetRoute, stageAction?.params) return true } diff --git a/src/hooks/useSocialAuth.js b/src/hooks/useSocialAuth.js index 895975e..7868b5f 100644 --- a/src/hooks/useSocialAuth.js +++ b/src/hooks/useSocialAuth.js @@ -72,8 +72,8 @@ const generateNonce = (length = 32) => { return result } -const ensureUserDocument = async (user, overrides = {}) => { - if (!user?.uid) return +const ensureUserDocument = async (user, overrides = {}, isNewUser = false) => { + if (!user?.uid) return null try { const docRef = usersRef.doc(user.uid) @@ -88,6 +88,9 @@ const ensureUserDocument = async (user, overrides = {}) => { payload.createdAt = firebase.firestore.FieldValue.serverTimestamp() if (user.email) payload.email = user.email payload.emailNotifications = true + if (isNewUser) { + payload.registrationFlowRequired = true + } } else if (typeof existingData?.emailNotifications === 'undefined') { payload.emailNotifications = true } @@ -122,8 +125,16 @@ const ensureUserDocument = async (user, overrides = {}) => { } await docRef.set(payload, { merge: true }) + return { + isNewUser, + userData: { + ...existingData, + ...payload, + }, + } } catch (error) { console.log('ensureUserDocument error', error?.message) + throw error } } @@ -221,10 +232,14 @@ const useSocialAuth = ({ onSuccess } = {}) => { const credential = firebase.auth.GoogleAuthProvider.credential(idToken) - await firebase.auth().signInWithCredential(credential) - await ensureUserDocument(firebase.auth().currentUser) + const userCredential = await firebase.auth().signInWithCredential(credential) + const authResult = await ensureUserDocument( + userCredential?.user, + {}, + userCredential?.additionalUserInfo?.isNewUser === true + ) if (typeof onSuccess === 'function') { - await onSuccess() + await onSuccess(authResult) } } catch (error) { console.log('Google sign-in error', error?.message) @@ -298,10 +313,14 @@ const useSocialAuth = ({ onSuccess } = {}) => { const provider = new firebase.auth.GoogleAuthProvider() provider.addScope('profile') provider.addScope('email') - await firebase.auth().signInWithPopup(provider) - await ensureUserDocument(firebase.auth().currentUser) + const userCredential = await firebase.auth().signInWithPopup(provider) + const authResult = await ensureUserDocument( + userCredential?.user, + {}, + userCredential?.additionalUserInfo?.isNewUser === true + ) if (typeof onSuccess === 'function') { - await onSuccess() + await onSuccess(authResult) } setIsLoading(false) } catch (error) { @@ -398,17 +417,21 @@ const useSocialAuth = ({ onSuccess } = {}) => { const firstName = fullName.givenName?.trim() || null const lastName = fullName.familyName?.trim() || null - await ensureUserDocument(userCredential?.user, { - firstName, - lastName, - displayName: - userCredential?.user?.displayName || - [firstName, lastName].filter(Boolean).join(' ') || - null, - }) + const authResult = await ensureUserDocument( + userCredential?.user, + { + firstName, + lastName, + displayName: + userCredential?.user?.displayName || + [firstName, lastName].filter(Boolean).join(' ') || + null, + }, + userCredential?.additionalUserInfo?.isNewUser === true + ) if (typeof onSuccess === 'function') { - await onSuccess() + await onSuccess(authResult) } setIsLoading(false) } catch (error) { diff --git a/src/layouts/Page.js b/src/layouts/Page.js index 5f301c7..0bb79b6 100644 --- a/src/layouts/Page.js +++ b/src/layouts/Page.js @@ -49,6 +49,8 @@ export default ({ connect = false, blurIntensity = 0, backgroundColor = '#000', + backgroundContent = null, + coinBadgeContainerStyle = null, showCoin = true, showReturnHome = false, onReturnHome = null, @@ -209,8 +211,9 @@ export default ({ }} /> ) : null} + {backgroundContent} {coinBadgeContainerVisible ? ( - + {showCoinBadge ? ( {} diff --git a/src/screens/CreatePassword.js b/src/screens/CreatePassword.js index 4f3ff75..5593784 100644 --- a/src/screens/CreatePassword.js +++ b/src/screens/CreatePassword.js @@ -13,7 +13,7 @@ import firebase, { usersRef } from '../config/firebase' import Page from '../layouts/Page' import { isWeb } from '../hooks/useLayoutType' import { Routes } from '../navigation' -import { navigate } from '../navigation/NavigationService' +import { reset } from '../navigation/NavigationService' import { Palette } from '../styles' import { FONT_FAMILY } from '../styles/Fonts' @@ -115,13 +115,14 @@ const CreatePassword = () => { ...(birthDate ? { birthDate } : {}), ...(country?.code ? { countryCode: country.code } : {}), ...(country?.name ? { countryName: country.name } : {}), + registrationFlowRequired: true, createdAt: firebase.firestore.FieldValue.serverTimestamp(), updatedAt: firebase.firestore.FieldValue.serverTimestamp(), }, { merge: true } ) setTooltip({ text: 'Compte créé, continuons', type: 'success' }) - navigate(Routes.BottomTab) + reset({ index: 0, routes: [{ name: Routes.RegistrationChoice }] }) } catch (e) { console.log('Register error', e?.message) const message = diff --git a/src/screens/Home/Home.js b/src/screens/Home/Home.js index 0c29949..807ac03 100644 --- a/src/screens/Home/Home.js +++ b/src/screens/Home/Home.js @@ -295,7 +295,7 @@ const Home = ({ navigation, route }) => { if (selectedProject?.id !== currentProject.id) { selectProject(currentProject.id) } - const targetRoute = beatmakerStage?.route || Routes.Compose + const targetRoute = beatmakerStage?.route || Routes.ComposeSong navigate(targetRoute, beatmakerStage?.params) }, }, @@ -324,7 +324,7 @@ const Home = ({ navigation, route }) => { const songwriterAction = useMemo(() => getStageAction('songwriter', null), []) const handleStartNew = useCallback(async () => { - const targetRoute = songwriterAction?.route || Routes.WritingLyrics + const targetRoute = songwriterAction?.route || Routes.CreateLyricsWithAi const targetParams = songwriterAction?.params try { diff --git a/src/screens/Home/components/ClubCard.js b/src/screens/Home/components/ClubCard.js index 8f7a5aa..5196dc1 100644 --- a/src/screens/Home/components/ClubCard.js +++ b/src/screens/Home/components/ClubCard.js @@ -15,6 +15,8 @@ const ClubCard = ({ onPress, containerStyle = null, variant = 'compact' }) => { return ( [ styles.card, cardBaseStyle, diff --git a/src/screens/Home/components/StageCard.js b/src/screens/Home/components/StageCard.js index 2a408cf..976ec6a 100644 --- a/src/screens/Home/components/StageCard.js +++ b/src/screens/Home/components/StageCard.js @@ -174,8 +174,8 @@ const StageCard = ({ isMobileVariant ? { textAlign: 'center' } : { textAlign: 'left', marginRight: 10 }, { fontFamily: FONT_FAMILY.InterRegular, - fontSize: 13, - lineHeight: 20, + fontSize: 14, + lineHeight: 21, color: Palette.white, }, ]} diff --git a/src/screens/Login.js b/src/screens/Login.js index c78b8ea..deefa78 100644 --- a/src/screens/Login.js +++ b/src/screens/Login.js @@ -9,7 +9,7 @@ import { background } from '../assets' import GradientButton from '../components/GradientButton.js' import { Input } from '../components/Input.js' import ItemContainer from '../components/ItemContainer/ItemContainer' -import firebase from '../config/firebase' +import firebase, { usersRef } from '../config/firebase' import useSocialAuth from '../hooks/useSocialAuth' import { isWeb } from '../hooks/useLayoutType' import Page from '../layouts/Page.js' @@ -17,16 +17,22 @@ import { Routes } from '../navigation' import { navigate } from '../navigation/NavigationService.js' import { FONT_FAMILY } from '../styles/Fonts.js' import Palette from '../styles/Palette.js' +import { getRouteAfterAuthentication } from '../utils/registrationFlow' export default ({ navigation }) => { const [, setTooltip] = useGlobal('_tooltip') const [email, setEmail] = useState(__DEV__ ? 'az@az.az' : '') const [password, setPassword] = useState(__DEV__ ? 'Minuit33' : '') const [loading, setLoading] = useState(false) - const afterLoginNavigate = useCallback(async () => { + const afterLoginNavigate = useCallback(async (authResult) => { const uid = firebase.auth().currentUser?.uid 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]) const { diff --git a/src/screens/Playback/Playback.js b/src/screens/Playback/Playback.js index da1c9be..5dff195 100644 --- a/src/screens/Playback/Playback.js +++ b/src/screens/Playback/Playback.js @@ -1,8 +1,8 @@ -import React, { useCallback, useState } from 'react' -import { Image, StyleSheet, View } from 'react-native' -import { ai, background } from '../../assets' +import React, { useCallback } from 'react' +import { StyleSheet, View } from 'react-native' +import { background } from '../../assets' +import BackgroundVideo from '../../components/BackgroundVideo' import BorderGradientButton from '../../components/BorderGradientButton' -import FullscreenIntroVideo from '../../components/FullscreenIntroVideo' import GradientButton from '../../components/GradientButton' import MusicLandHeader from '../../components/MusicLandHeader' import Page from '../../layouts/Page' @@ -15,13 +15,7 @@ import { gutters } from '../../styles' const Playback = ({ route, navigation }) => { const { project, returnToAdventureModal = false } = route.params || {} const { videos } = useUser() - // const benhaiUrl = isWeb ? videos?.benhaiWeb || null : videos?.benhai; const theoUrl = isWeb ? videos?.theoWeb : videos?.theo - const [showIntro, setShowIntro] = useState(theoUrl) - - const handleCloseIntro = () => { - setShowIntro(null) - } const handleBackPress = useCallback(() => { if (returnToAdventureModal) { @@ -39,6 +33,13 @@ const Playback = ({ route, navigation }) => { + } + containerStyle={isWeb ? styles.videoPage : undefined} > {/* */} @@ -58,7 +59,6 @@ const Playback = ({ route, navigation }) => { {/* */} - ) } @@ -66,10 +66,12 @@ const Playback = ({ route, navigation }) => { export default Playback const styles = StyleSheet.create({ - img: { - width: '100%', - height: '70%', - position: 'absolute', - bottom: -40, + videoPage: { + alignSelf: 'flex-start', + marginLeft: '4%', + }, + rightSideControl: { + right: 24, + left: 'auto', }, }) diff --git a/src/screens/Publishing/PublishYoutube.js b/src/screens/Publishing/PublishYoutube.js index 6a3581a..182edfb 100644 --- a/src/screens/Publishing/PublishYoutube.js +++ b/src/screens/Publishing/PublishYoutube.js @@ -4,6 +4,7 @@ import { Linking, StyleSheet, Text, View } from 'react-native' import useMinuit from 'react-native-minuit/src/hooks/useMinuit.js' import { background } from '../../assets' import AppCheckbox from '../../components/AppCheckbox' +import BackgroundVideo from '../../components/BackgroundVideo' import BorderGradientButton from '../../components/BorderGradientButton' import GradientButton from '../../components/GradientButton' import MusicLandHeader from '../../components/MusicLandHeader' @@ -15,11 +16,12 @@ import { Routes } from '../../navigation' import { useUser } from '../../providers/UserDataProvider' import { Palette, gutters } from '../../styles' import { FONT_FAMILY } from '../../styles/Fonts' +import { isWeb } from '../../hooks/useLayoutType' const PublishYoutube = () => { const route = useRoute() const routeProjectId = route?.params?.projectId ?? null - const { userProjects = [], selectedProject } = useUser() + const { userProjects = [], selectedProject, videos } = useUser() const { setTooltip } = useMinuit() const project = useMemo(() => { @@ -33,6 +35,7 @@ const PublishYoutube = () => { }, [routeProjectId, selectedProject, userProjects]) const projectId = project?.id || routeProjectId || null + const introVideo = isWeb ? videos?.benhaiWeb : videos?.benhai const [saving, setSaving] = useState(false) const [hasAcceptedPublication, setHasAcceptedPublication] = useState(true) @@ -194,7 +197,17 @@ const PublishYoutube = () => { }, []) return ( - + + } + containerStyle={isWeb ? styles.videoPage : undefined} + headerType="NONE" + > { } const styles = StyleSheet.create({ + videoPage: { + alignSelf: 'center', + }, + rightSideControl: { + right: 24, + left: 'auto', + }, consentContainer: { gap: 6, }, diff --git a/src/screens/Register.js b/src/screens/Register.js index 0ca9eab..48d3c0d 100644 --- a/src/screens/Register.js +++ b/src/screens/Register.js @@ -27,6 +27,7 @@ import { Routes } from '../navigation' import { navigate, reset } from '../navigation/NavigationService' import { Palette } from '../styles' import { FONT_FAMILY } from '../styles/Fonts' +import { getRouteAfterAuthentication } from '../utils/registrationFlow' const LANGUAGE_STORAGE_KEY = 'preferredLanguage' @@ -39,10 +40,13 @@ const Register = () => { const [showDatePicker, setShowDatePicker] = useState(false) const [, setTooltip] = useGlobal('_tooltip') - const afterSocialAuth = useCallback(async () => { + const afterSocialAuth = useCallback(async (authResult) => { const uid = firebase.auth().currentUser?.uid 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 { diff --git a/src/screens/RegistrationChoice.js b/src/screens/RegistrationChoice.js new file mode 100644 index 0000000..0b4c3ba --- /dev/null +++ b/src/screens/RegistrationChoice.js @@ -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 ( + + + + + + Que veux-tu faire en premier ? + + Tu pourras passer librement de la création à l’écoute après cette étape. + + + + + + Création + + Commence ton parcours avec les 10 crédits offerts à ton inscription. + + + + + + + + + + Écoute + + Retrouve les créations de la communauté dans l’espace Streaming. + + + + + + + + + ) +} + +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 diff --git a/src/screens/Splash.js b/src/screens/Splash.js index 645dc54..dfdb01b 100644 --- a/src/screens/Splash.js +++ b/src/screens/Splash.js @@ -6,6 +6,7 @@ import { Routes } from '../navigation' import { SplashAnimationContext } from '../providers/SplashAnimationProvider' import { UserDataContext } from '../providers/UserDataProvider' +import { getRouteAfterAuthentication } from '../utils/registrationFlow' export default ({ navigation }) => { const { setCurrentUserData } = useContext(UserDataContext) @@ -28,7 +29,7 @@ export default ({ navigation }) => { index: 0, routes: [ { - name: Routes.BottomTab, + name: getRouteAfterAuthentication(user), }, ], }) diff --git a/src/screens/Studio/ComposeSong.js b/src/screens/Studio/ComposeSong.js index 5bcce51..8619dcc 100644 --- a/src/screens/Studio/ComposeSong.js +++ b/src/screens/Studio/ComposeSong.js @@ -3,6 +3,7 @@ import { useRoute } from '@react-navigation/native' import { Dimensions, Modal, Text, View } from 'react-native' import SwiperFlatList from 'react-native-swiper-flatlist' import { background, icons } from '../../assets' +import BackgroundVideo from '../../components/BackgroundVideo' import BorderGradientButton from '../../components/BorderGradientButton' import GradientButton from '../../components/GradientButton' import MusicLandHeader from '../../components/MusicLandHeader' @@ -41,8 +42,14 @@ const ComposeSong = () => { const [progress, setProgress] = useState(18) const [containerLayout, setContainerLayout] = useState(null) const route = useRoute() - const { selectedProjectId, selectedProject, updateProjectData, currentUserData, currentUID } = - useUser() + const { + selectedProjectId, + selectedProject, + updateProjectData, + currentUserData, + currentUID, + videos, + } = useUser() // Selections state const [genres, setGenres] = useState([]) @@ -286,7 +293,11 @@ const ComposeSong = () => { ]) return ( - + } + headerType="NONE" + > { }, [windowWidth]) const containerWidth = parentLayout?.width || estimatedContainerWidth const route = useRoute() - const { selectedProjectId, selectedProject, updateProjectData, currentUserData, currentUID } = - useUser() + const { + selectedProjectId, + selectedProject, + updateProjectData, + currentUserData, + currentUID, + videos, + } = useUser() const [genres, setGenres] = useState([]) const [voice, setVoice] = useState({}) @@ -288,7 +295,18 @@ const ComposeSong = () => { } return ( - + + } + coinBadgeContainerStyle={styles.rightSideCredits} + containerStyle={styles.videoQuestionPage} + headerType="NONE" + > { } 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', + }, +}) diff --git a/src/screens/Studio/Studio.js b/src/screens/Studio/Studio.js index 731d407..4c32c40 100644 --- a/src/screens/Studio/Studio.js +++ b/src/screens/Studio/Studio.js @@ -111,7 +111,7 @@ const Studio = () => { } else { if (!selectedProject?.id) return selectProject(selectedProject.id) - navigate(Routes.Compose) + navigate(Routes.ComposeSong) } }} /> @@ -130,7 +130,7 @@ const Studio = () => { /> {!selectedProject?.coverUrl && ( { if (typeof amount !== 'number') { @@ -302,6 +304,10 @@ export default function Subscriptions() { const route = useRoute() const insets = useSafeAreaInsets() const isMobile = !isWeb + const isRegistrationFlow = route?.params?.registrationFlow === true + const registrationChoice = route?.params?.registrationChoice + const canSkipRegistrationPayment = + isRegistrationFlow && registrationChoice === 'creation' const initialPack = React.useMemo(() => { const rawPack = route?.params?.pack return typeof rawPack === 'string' ? rawPack.toLowerCase() : null @@ -318,12 +324,13 @@ export default function Subscriptions() { createSubscriptionCheckout, createCoinPackCheckout, } = useStripe() - const { currentUserData } = useUserData() + const { currentUserData, hasActiveSubscription } = useUserData() const [selectedPeriodKey, setSelectedPeriodKey] = React.useState('monthly') const [selectedPriceId, setSelectedPriceId] = React.useState(null) const [processingPriceId, setProcessingPriceId] = React.useState(null) const [errorMessage, setErrorMessage] = React.useState(null) const initialPackHandledRef = React.useRef(false) + const registrationCompletedRef = React.useRef(false) const normalizedPlansByPeriod = React.useMemo( () => ({ @@ -344,7 +351,43 @@ export default function Subscriptions() { const hasAnyPlan = availablePeriods.length > 0 const isLoadingPlans = isCatalogLoading && !hasAnyPlan 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(() => { initialPackHandledRef.current = false @@ -721,9 +764,23 @@ export default function Subscriptions() { disabled={isActionDisabled} gradientStyle={[styles.actionButtonGradient, styles.mobileActionButtonGradient]} /> + {canSkipRegistrationPayment ? ( + + ) : null} ) - }, [actionButtonTitle, handleCheckout, isActionDisabled, mobileActionSafePadding]) + }, [ + actionButtonTitle, + canSkipRegistrationPayment, + handleCheckout, + handleSkipRegistrationPayment, + isActionDisabled, + mobileActionSafePadding, + ]) const renderWebBackButton = React.useCallback(() => { if (isMobile) return null @@ -803,7 +860,7 @@ export default function Subscriptions() { ))} - {coinPacks && coinPacks.length > 0 ? ( + {!isRegistrationFlow && coinPacks && coinPacks.length > 0 ? ( {COIN_PACK_SECTION_TITLE} @@ -871,6 +928,13 @@ export default function Subscriptions() { disabled={isActionDisabled} gradientStyle={styles.actionButtonGradient} /> + {canSkipRegistrationPayment ? ( + + ) : null} {SUBSCRIPTION_DISCLAIMER} @@ -1440,6 +1504,9 @@ const styles = StyleSheet.create({ shadowRadius: 12, elevation: 12, }, + skipPaymentButton: { + width: '100%', + }, mobileActionButtonGradient: { width: '100%', }, diff --git a/src/screens/Writing/CreateLyricsWithAi.js b/src/screens/Writing/CreateLyricsWithAi.js index 3186833..c313d5f 100644 --- a/src/screens/Writing/CreateLyricsWithAi.js +++ b/src/screens/Writing/CreateLyricsWithAi.js @@ -3,6 +3,7 @@ import { Dimensions, KeyboardAvoidingView, Platform, View } from 'react-native' import { responsiveHeight } from 'react-native-responsive-dimensions' import { SwiperFlatList } from 'react-native-swiper-flatlist' import { background } from '../../assets' +import BackgroundVideo from '../../components/BackgroundVideo' import GradientButton from '../../components/GradientButton' import MusicLandHeader from '../../components/MusicLandHeader' import { OTHER_OBJECTIVE_OPTION, OTHER_STYLE_OPTION } from '../../data/data' @@ -26,7 +27,7 @@ const CUSTOM_SANITIZE_OPTIONS = { autoInjectPreChorus: false } const CTA_BUTTON_MAX_WIDTH = 500 const CreateLyricsWithAi = () => { - const { selectedProject } = useUser() + const { selectedProject, videos } = useUser() const scrollRef = useRef(null) const [selectedIndex, setSelectedIndex] = useState(0) const [progress, setProgress] = useState(16) @@ -341,9 +342,16 @@ const CreateLyricsWithAi = () => { }, [selectedIndex, parentLayout?.height]) const isCtaVisible = selectedIndex !== 8 + const isQuestionStep = selectedIndex < MAX_STEP_INDEX return ( - + : null + } + > { const nextHeight = event?.nativeEvent?.layout?.height diff --git a/src/screens/Writing/CreateLyricsWithAi.web.js b/src/screens/Writing/CreateLyricsWithAi.web.js index 49a62ad..1e27ce7 100644 --- a/src/screens/Writing/CreateLyricsWithAi.web.js +++ b/src/screens/Writing/CreateLyricsWithAi.web.js @@ -1,7 +1,8 @@ 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 { background } from '../../assets' +import BackgroundVideo from '../../components/BackgroundVideo' import GradientButton from '../../components/GradientButton' import MusicLandHeader from '../../components/MusicLandHeader' import { OTHER_OBJECTIVE_OPTION, OTHER_STYLE_OPTION } from '../../data/data' @@ -24,7 +25,7 @@ const MAX_STEP_INDEX = 8 const CUSTOM_SANITIZE_OPTIONS = { autoInjectPreChorus: false } const CreateLyricsWithAi = () => { - const { selectedProject } = useUser() + const { selectedProject, videos } = useUser() const [selectedIndex, setSelectedIndex] = useState(0) const handleLyricsError = React.useCallback(() => { setSelectedIndex(7) @@ -385,13 +386,18 @@ const CreateLyricsWithAi = () => { ] const currentStep = steps[selectedIndex] + const isQuestionStep = selectedIndex < MAX_STEP_INDEX return ( : null + } + containerStyle={isQuestionStep ? styles.videoQuestionPage : undefined} > { } export default CreateLyricsWithAi + +const styles = StyleSheet.create({ + videoQuestionPage: { + alignSelf: 'flex-end', + marginRight: '4%', + }, +}) diff --git a/src/screens/Writing/Lyrics.js b/src/screens/Writing/Lyrics.js index 137846b..b54cefc 100644 --- a/src/screens/Writing/Lyrics.js +++ b/src/screens/Writing/Lyrics.js @@ -328,7 +328,7 @@ const Lyrics = ({ navigation }) => { return } const handleNavigateToStudio = () => { - const targetRoute = beatmakerStage?.route || Routes.Compose + const targetRoute = beatmakerStage?.route || Routes.ComposeSong navigate(targetRoute, { ...(beatmakerStage?.params || {}), fromLyrics: true, diff --git a/src/screens/cover/ChooseCoverType.js b/src/screens/cover/ChooseCoverType.js index 3baf325..69e25d9 100644 --- a/src/screens/cover/ChooseCoverType.js +++ b/src/screens/cover/ChooseCoverType.js @@ -3,7 +3,6 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react' import { Modal, Platform, Pressable, StyleSheet, Text, View } from 'react-native' import useMinuit from 'react-native-minuit/src/hooks/useMinuit' import BorderGradientButton from '../../components/BorderGradientButton' -import FullscreenIntroVideo from '../../components/FullscreenIntroVideo' import GradientButton from '../../components/GradientButton' import { Input } from '../../components/Input' import MusicLandHeader from '../../components/MusicLandHeader' @@ -19,7 +18,6 @@ import { background } from '../../assets' export const ChooseCoverType = () => { const isIOS = Platform.OS === 'ios' - const [showIntro, setShowIntro] = useState(true) const [choiceVisible, setChoiceVisible] = useState(false) const [pseudoVisible, setPseudoVisible] = useState(false) const [choiceDismissAction, setChoiceDismissAction] = useState(null) @@ -33,12 +31,6 @@ export const ChooseCoverType = () => { const { setTooltip } = useMinuit() 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(() => { if (currentUserData?.userName) return true const pref = currentUserData?.artistNamePreference @@ -108,13 +100,9 @@ export const ChooseCoverType = () => { [currentUID, projectId] ) - const handleGenerateCover = useCallback(() => { - if (hasFinalCover || hasGeneratedOptions) { - navigate(Routes.ValidateCover) - return - } + const handleShowCover = useCallback(() => { ensureArtistPreference(() => navigate(Routes.PouchReady)) - }, [ensureArtistPreference, hasFinalCover, hasGeneratedOptions]) + }, [ensureArtistPreference]) const closeChoiceModal = useCallback(() => { setChoiceVisible(false) @@ -276,15 +264,11 @@ export const ChooseCoverType = () => { onPress={handlePickUserImage} /> */} - {/* setShowIntro(false)}*/} - {/*/>*/} { - const { selectedProjectId, selectedProject, updateProjectData } = useUser() - const { setLoading } = useGlobalLoading() - const isGenerating = selectedProject?.coverStatus === 'GENERATING' - const coverOptions = useMemo(() => { - if (!Array.isArray(selectedProject?.cover?.options)) { - return [] - } - return selectedProject.cover.options.filter(Boolean) - }, [selectedProject?.cover?.options]) - const hasGeneratedOptions = coverOptions.length > 0 - const selectedOptionId = selectedProject?.cover?.selectedOptionId || null + const { + selectedProjectId, + selectedProject, + currentUserData, + updateProjectData, + } = useUser() + const requestStartedRef = useRef(false) + const [isSaving, setIsSaving] = useState(false) + + const templateCover = + selectedProject?.cover?.template === COVER_TEMPLATE ? selectedProject.cover : null + const coverUrl = templateCover?.result || null + const isPreparing = selectedProject?.coverStatus === 'GENERATING' + const hasPreparationError = selectedProject?.coverStatus === 'ERROR' + const artistName = + typeof selectedProject?.userName === 'string' && selectedProject.userName.trim() + ? selectedProject.userName.trim() + : currentUserData?.userName || 'MusicLand' + const profilePictureURL = currentUserData?.profilePictureURL || null + const selectedOption = useMemo(() => { - if (!coverOptions.length) { - return null + if (!coverUrl) { + return isWeb + ? { + id: COVER_OPTION_ID, + finalUrl: null, + generatedUrl: null, + } + : null } - const found = coverOptions.find((option) => option?.id === selectedOptionId) - return found || coverOptions[0] || null - }, [coverOptions, selectedOptionId]) - const coverBackgroundMessage = isWeb ? loaderMessages.pouchReadyGenerationWeb : '' - const generationLoadingMessage = useMemo(() => { - if (typeof coverBackgroundMessage === 'string') { - const trimmedMessage = coverBackgroundMessage.trim() - if (trimmedMessage.length) { - return trimmedMessage + const savedOption = Array.isArray(templateCover?.options) ? templateCover.options[0] : null + return ( + savedOption || { + id: COVER_TEMPLATE, + finalUrl: coverUrl, + generatedUrl: null, } - } - return 'Nous lançons la génération de ta pochette...' - }, [coverBackgroundMessage]) + ) + }, [coverUrl, templateCover?.options]) - const [isEditing, setIsEditing] = useState(false) - const [styleMode, setStyleMode] = useState('preset') - const [selectedPresetStyle, setSelectedPresetStyle] = useState(COVER_STYLE_PRESETS[0]) - const [customStyle, setCustomStyle] = useState('') - const [isPresetDropdownOpen, setIsPresetDropdownOpen] = useState(false) - const [isSelecting, setIsSelecting] = useState(false) - const [isAwaitingGenerationStart, setIsAwaitingGenerationStart] = useState(false) - const [coverProgress, setCoverProgress] = useState(0) - const coverProgressIntervalRef = useRef(null) - const coverProgressStartRef = useRef(null) - const { height: windowHeight } = useWindowDimensions() - const dropdownMaxHeight = !isWeb - ? Math.max(220, Math.min(340, Math.round(windowHeight * 0.45))) - : null - - const showGenerationLoading = useCallback(async () => { - setIsAwaitingGenerationStart(true) - await setLoading(true, { message: generationLoadingMessage }) - }, [generationLoadingMessage, setLoading]) - - useEffect(() => { - const projectStyle = (selectedProject?.coverStyle || '').trim() - const isPresetStyle = COVER_STYLE_PRESETS.includes(projectStyle) - - if (!projectStyle) { - setStyleMode('preset') - setSelectedPresetStyle(COVER_STYLE_PRESETS[0]) - setCustomStyle('') - setIsPresetDropdownOpen(false) + const requestCoverPreparation = useCallback(async () => { + if (!selectedProjectId || requestStartedRef.current || isPreparing) { return } - - if (isPresetStyle) { - setStyleMode('preset') - setSelectedPresetStyle(projectStyle) - setCustomStyle('') - } else { - setStyleMode('custom') - setCustomStyle(projectStyle) - } - setIsPresetDropdownOpen(false) - }, [selectedProject?.coverStyle]) - - const generateCover = useCallback( - async (styleValue, { allowRegeneration = false } = {}) => { - if (!selectedProjectId) return - // We allow regeneration if isEditing or explicitly requested - if (hasGeneratedOptions && !isEditing && !allowRegeneration) { - return - } - const trimmedStyle = String(styleValue || '').trim() - if (!trimmedStyle) { - alert('Attention', 'Merci de renseigner un style pour la pochette.', [{ text: 'OK' }]) - return - } - try { - await showGenerationLoading() - await updateProjectData( - { - coverStyle: trimmedStyle, - cover: { - options: [], - result: null, - generatedBackground: null, - selectedOptionId: null, - }, - }, - { merge: true } - ) - await tasksRef.add({ - type: 'cover', - projectId: selectedProjectId, - status: 'PENDING', - createdAt: firebase.firestore.FieldValue.serverTimestamp(), - }) - setIsEditing(false) // Exit editing mode after triggering generation - } catch (e) { - console.log('Cover task error', e?.message) - setIsAwaitingGenerationStart(false) - await setLoading(false) - } - }, - [hasGeneratedOptions, isEditing, selectedProjectId, setLoading, showGenerationLoading, updateProjectData] - ) - - const requestCoverGeneration = useCallback(() => { - const selectedStyle = styleMode === 'preset' ? selectedPresetStyle : customStyle - const trimmedStyle = (selectedStyle || '').trim() - if (!trimmedStyle) { - alert('Attention', 'Merci de renseigner un style pour la pochette.', [{ text: 'OK' }]) - return - } - generateCover(trimmedStyle) - }, [customStyle, generateCover, selectedPresetStyle, styleMode]) - - const handleRegenerateSameStyle = useCallback(() => { - const storedStyle = - typeof selectedProject?.coverStyle === 'string' ? selectedProject.coverStyle.trim() : '' - const fallbackStyle = styleMode === 'preset' ? selectedPresetStyle : customStyle - const trimmedStyle = (storedStyle || fallbackStyle || '').trim() - if (!trimmedStyle) { - alert('Attention', 'Merci de renseigner un style pour la pochette.', [{ text: 'OK' }]) - return - } - generateCover(trimmedStyle, { allowRegeneration: true }) - }, [customStyle, generateCover, selectedPresetStyle, selectedProject?.coverStyle, styleMode]) - - const coverPreviewUrl = - selectedProject?.cover?.result || selectedProject?.cover?.generatedBackground || null - - const shouldShowStylePanel = (!isGenerating && !hasGeneratedOptions && !coverPreviewUrl) || isEditing - - const displayOptions = hasGeneratedOptions - ? coverOptions - : coverPreviewUrl - ? [ + requestStartedRef.current = true + try { + await updateProjectData( { - id: 'preview', - finalUrl: selectedProject?.cover?.result || null, - generatedUrl: coverPreviewUrl, + coverStatus: 'PENDING', + cover: { + template: COVER_TEMPLATE, + result: null, + selectedOptionId: COVER_TEMPLATE, + options: [], + }, }, - ] - : [] - const isCoverLoading = isGenerating && !hasGeneratedOptions && !coverPreviewUrl - const coverProgressValue = Math.max(0, Math.min(100, Math.round(coverProgress))) - const isGenerationStartPending = isAwaitingGenerationStart && !isGenerating + { merge: true } + ) + await tasksRef.add({ + type: 'cover', + projectId: selectedProjectId, + status: 'PENDING', + createdAt: firebase.firestore.FieldValue.serverTimestamp(), + }) + } catch (error) { + requestStartedRef.current = false + console.log('PouchReady: unable to prepare cover', error?.message) + } + }, [isPreparing, selectedProjectId, updateProjectData]) useEffect(() => { - const clearProgressInterval = () => { - if (coverProgressIntervalRef.current) { - global.clearInterval(coverProgressIntervalRef.current) - coverProgressIntervalRef.current = null - } + if (isWeb || coverUrl || isPreparing || hasPreparationError) { + return } + requestCoverPreparation() + }, [coverUrl, hasPreparationError, isPreparing, requestCoverPreparation]) - if (!isCoverLoading) { - clearProgressInterval() - coverProgressStartRef.current = null - setCoverProgress(hasGeneratedOptions || coverPreviewUrl ? 100 : 0) - return clearProgressInterval - } + const retryPreparation = useCallback(() => { + requestStartedRef.current = false + requestCoverPreparation() + }, [requestCoverPreparation]) - coverProgressStartRef.current = new Date() - setCoverProgress(0) - clearProgressInterval() - coverProgressIntervalRef.current = global.setInterval(() => { - const start = coverProgressStartRef.current - if (!start) return - const elapsed = Date.now() - start.getTime() - const ratio = Math.max(0, Math.min(1, elapsed / COVER_FAKE_DURATION_MS)) - const next = COVER_PROGRESS_MAX * ratio - setCoverProgress((prev) => { - if (prev >= COVER_PROGRESS_MAX) return COVER_PROGRESS_MAX - return next >= COVER_PROGRESS_MAX ? COVER_PROGRESS_MAX : next - }) - }, COVER_PROGRESS_INTERVAL_MS) - - return clearProgressInterval - }, [isCoverLoading, hasGeneratedOptions, coverPreviewUrl]) - - const handleSelectOption = useCallback( - async (option) => { - if (!option || option?.id === selectedOptionId || isSelecting || !option?.id) { - return - } - const existingCover = selectedProject?.cover || {} - const finalUrl = option.finalUrl || option.generatedUrl || null - setIsSelecting(true) - try { - await updateProjectData({ - cover: { - ...existingCover, - options: coverOptions, - selectedOptionId: option.id, - result: finalUrl, - generatedBackground: option.generatedUrl || option.finalUrl || null, - }, - }) - } catch (e) { - console.log('PouchReady: unable to select cover', e?.message) - } finally { - setIsSelecting(false) - } - }, - [coverOptions, isSelecting, selectedOptionId, selectedProject?.cover, updateProjectData] - ) - - const onValidatePicture = useCallback(() => { + const onValidatePicture = useCallback(async () => { if (!selectedOption) { return } - navigate(Routes.SongDownload, { - project: selectedProject, - selectedOption, - coverOptions, - backRoute: Routes.PouchReady, - }) - }, [coverOptions, navigate, selectedOption, selectedProject]) + try { + setIsSaving(true) + let optionToSave = selectedOption + let projectToSave = selectedProject - const isPrimaryActionDisabled = - isGenerating || (hasGeneratedOptions ? !selectedOption || isSelecting : true) - - useEffect(() => { - if (!isAwaitingGenerationStart) { - return - } - if (isGenerating) { - setIsAwaitingGenerationStart(false) - setLoading(false) - } - }, [isAwaitingGenerationStart, isGenerating, setLoading]) - - useEffect( - () => () => { - if (isAwaitingGenerationStart) { - setIsAwaitingGenerationStart(false) - setLoading(false) + if (isWeb && !coverUrl) { + const userId = selectedProject?.userId + if (!userId || !selectedProjectId) { + throw new Error('cover_project_identity_missing') + } + const coverBlob = await createMusiclandPlaybackCover({ + artistName, + profilePictureURL, + }) + const storagePath = `users/${userId}/projects/${selectedProjectId}/cover-musicland-playbacker.png` + const { resultURI } = await uploadFileToFirebase({ + path: storagePath, + fileType: 'IMAGE', + blob: coverBlob, + }) + optionToSave = { + id: COVER_OPTION_ID, + finalUrl: resultURI, + generatedUrl: null, + } + const cover = { + template: COVER_TEMPLATE, + result: resultURI, + generatedBackground: null, + selectedOptionId: COVER_OPTION_ID, + options: [optionToSave], + } + await updateProjectData( + { + cover, + coverStatus: 'GENERATED', + }, + { merge: true } + ) + projectToSave = { + ...selectedProject, + cover, + coverStatus: 'GENERATED', + } } - }, - [isAwaitingGenerationStart, setLoading] - ) + + navigate(Routes.SongDownload, { + project: projectToSave, + selectedOption: optionToSave, + coverOptions: [optionToSave], + backRoute: Routes.PouchReady, + }) + } catch (error) { + console.log('PouchReady: unable to save cover', error?.message) + Alert.alert( + 'Enregistrement impossible', + 'La pochette n’a pas pu être enregistrée. Réessaie dans quelques instants.' + ) + } finally { + setIsSaving(false) + } + }, [ + artistName, + coverUrl, + profilePictureURL, + selectedOption, + selectedProject, + selectedProjectId, + updateProjectData, + ]) return ( - - - - + + + + - - - - {displayOptions.length > 0 ? ( - displayOptions.map((option, index) => { - const optionUri = option?.finalUrl || option?.generatedUrl || '' - if (!optionUri) return null - if (hasGeneratedOptions) { - const isSelected = option?.id === selectedOption?.id - const cardWidthStyle = isWeb - ? styles.coverOptionCardWeb - : styles.coverOptionCardMobile - return ( - handleSelectOption(option)} - disabled={isSelecting} - style={[ - styles.coverOptionCard, - cardWidthStyle, - isSelected ? styles.coverOptionCardSelected : null, - isSelecting ? styles.coverOptionCardDisabled : null, - ]} - > - - - - {`Option ${index + 1}`} - - - {isSelected && ( - - Sélectionnée - - )} - - ) - } - return ( - - - - ) - }) - ) : ( - <> - {isGenerating && ( - - - {isWeb ? ( - coverBackgroundMessage ? ( - - {coverBackgroundMessage} - - ) : null - ) : ( - - Génération en cours. - - )} - - - - {coverProgressValue}% - - - - )} - - // - )} - - {shouldShowStylePanel && ( - - - Style de la pochette - - Choisis une ambiance ou écris ton propre brief créatif. - - - - - { - setStyleMode('preset') - setIsPresetDropdownOpen(false) - if (!selectedPresetStyle) { - setSelectedPresetStyle(COVER_STYLE_PRESETS[0]) - } - }} - /> - - {styleMode === 'preset' && ( - - setIsPresetDropdownOpen((prev) => !prev)} - > - - {selectedPresetStyle} - - - - {isPresetDropdownOpen && ( - - - {COVER_STYLE_PRESETS.map((styleOption, index) => { - const isActive = selectedPresetStyle === styleOption - const isLast = index === COVER_STYLE_PRESETS.length - 1 - return ( - { - setSelectedPresetStyle(styleOption) - setIsPresetDropdownOpen(false) - }} - > - - {styleOption} - - {isActive && ( - - )} - - ) - })} - - - )} - - )} - - - { - setStyleMode('custom') - setIsPresetDropdownOpen(false) - }} - /> - - (donne les instructions que tu veux pour créer ta pochette) - - - {styleMode === 'custom' && ( - - - - )} - - - - )} + {!isWeb && !coverUrl && !hasPreparationError ? ( + + + Préparation de ta pochette… - + ) : null} + {hasPreparationError ? ( + + + La pochette n’a pas pu être préparée. Tu peux réessayer. + + + + ) : null} - - {isEditing ? ( - - - {isGenerationStartPending && ( - - - - Lancement de la génération de ta pochette... - - - )} - setIsEditing(false)} - disabled={isGenerating || isGenerationStartPending} - style={{ alignSelf: 'center', padding: 8 }} - > - - Annuler - - - - ) : !hasGeneratedOptions ? ( - - - {isGenerationStartPending && ( - - - - Lancement de la génération de ta pochette... - - - )} - - ) : ( - <> - - - - setIsEditing(true)} - disabled={isGenerating || isGenerationStartPending} - textStyle={{ fontSize: 13 }} - /> - - - )} - - + + + + ) } -export default PouchReady - const styles = StyleSheet.create({ - coverOptionCard: { - position: 'relative', - borderRadius: 20, - borderWidth: 1, - borderColor: 'rgba(255,255,255,0.12)', - overflow: 'hidden', - backgroundColor: 'rgba(15,12,20,0.4)', - }, - coverOptionCardWeb: { - width: 280, - maxWidth: 320, - }, - coverOptionCardMobile: { - width: 150, - alignSelf: 'center', - }, - coverOptionCardSelected: { - borderColor: Palette.primary, - }, - coverOptionCardDisabled: { - opacity: 0.85, - }, - coverOptionImage: { - width: '100%', - aspectRatio: 1, - }, - coverOptionBadge: { - position: 'absolute', - top: 12, - right: 12, - backgroundColor: Palette.transparentBlack, - paddingHorizontal: 10, - paddingVertical: 6, - borderRadius: 12, - }, - coverOptionBadgeLabel: { - color: Palette.white, - fontSize: 12, - fontFamily: FONT_FAMILY.InterSemiBold, - }, - coverOptionSelectedBadge: { - position: 'absolute', - bottom: 12, - left: 12, - backgroundColor: Palette.primary, - paddingHorizontal: 12, - paddingVertical: 6, - borderRadius: 12, - }, - coverOptionSelectedLabel: { - color: Palette.white, - fontSize: 12, - fontFamily: FONT_FAMILY.InterSemiBold, - }, - coverPreviewCard: { - alignItems: 'center', - }, - coverPreviewCardWeb: { - width: 300, - }, - coverPreviewCardMobile: { - width: '100%', - }, - coverPreviewImage: { - width: '100%', - height: 260, - borderRadius: 20, - }, - stylePanel: { - gap: 20, - paddingHorizontal: 18, - paddingVertical: 20, - borderRadius: 24, - borderWidth: 1, - borderColor: 'rgba(255,255,255,0.08)', - backgroundColor: 'rgba(15, 12, 20, 0.78)', - overflow: 'hidden', - }, - stylePanelWeb: { - paddingHorizontal: 26, - }, - stylePanelHeader: { - gap: 6, - }, - panelTitle: { - fontSize: 18, - color: Palette.white, - fontFamily: FONT_FAMILY.InterSemiBold, - }, - panelSubtitle: { - fontSize: 13, - color: Palette.gray, - fontFamily: FONT_FAMILY.InterRegular, - lineHeight: 18, - }, - modeContainer: { - gap: 18, - }, - modeCard: { - alignSelf: 'stretch', - paddingHorizontal: 16, - paddingVertical: 12, - borderRadius: 14, - borderWidth: 1, - borderColor: 'rgba(255,255,255,0.1)', - backgroundColor: 'rgba(15,12,20,0.6)', - }, - modeCardActive: { - borderColor: Palette.primary, - backgroundColor: 'rgba(251,104,168,0.12)', - }, - dropdownArea: { - alignSelf: 'stretch', - gap: 12, - }, - dropdownTrigger: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingHorizontal: 16, - paddingVertical: 14, - borderRadius: 16, - borderWidth: 1, - borderColor: 'rgba(255,255,255,0.1)', - backgroundColor: 'rgba(15,12,20,0.65)', - }, - dropdownTriggerLabel: { + pageContent: { flex: 1, - fontSize: 15, - color: Palette.white, - fontFamily: FONT_FAMILY.InterMedium, + marginTop: 8, }, - dropdownArrow: { - width: 16, - height: 16, - tintColor: Palette.gray, - marginLeft: 10, - }, - dropdownArrowOpen: { - transform: [ - { - rotate: '180deg', - }, - ], - }, - dropdownList: { - borderRadius: 16, - borderWidth: 1, - borderColor: 'rgba(255,255,255,0.08)', - backgroundColor: 'rgba(12,10,18,0.95)', - overflow: 'hidden', - }, - dropdownListContent: { - paddingVertical: 2, - }, - dropdownOption: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingHorizontal: 16, - paddingVertical: 14, - }, - dropdownOptionDivider: { - borderBottomWidth: 1, - borderBottomColor: 'rgba(255,255,255,0.06)', - }, - dropdownOptionActive: { - backgroundColor: 'rgba(251,104,168,0.12)', - }, - dropdownOptionLabel: { + previewArea: { flex: 1, - fontSize: 15, - color: Palette.white, - fontFamily: FONT_FAMILY.InterRegular, - }, - dropdownOptionLabelActive: { - color: Palette.primary, - fontFamily: FONT_FAMILY.InterSemiBold, - }, - dropdownOptionIcon: { - width: 18, - height: 18, - tintColor: Palette.primary, - marginLeft: 12, - }, - modeColumn: { - gap: 12, - minWidth: 240, - }, - coverProgressWrapper: { - alignItems: 'center', - gap: 10, - width: 220, - }, - coverProgressWrapperWeb: { - width: 260, - }, - coverProgressBar: { width: '100%', + paddingHorizontal: gutters, + gap: 16, }, - generationPendingRow: { + coverWeb: { + width: 360, + maxWidth: '70%', + }, + coverMobile: { + width: '86%', + maxWidth: 380, + }, + preparingRow: { + minHeight: 24, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', - gap: 8, + gap: 10, }, - generationPendingLabel: { + preparingText: { color: Palette.white, - fontSize: 13, fontFamily: FONT_FAMILY.InterRegular, + fontSize: 14, + }, + errorPanel: { + width: isWeb ? 360 : '86%', + maxWidth: 380, + gap: 12, + }, + errorText: { + color: Palette.white, + fontFamily: FONT_FAMILY.InterRegular, + fontSize: 14, + lineHeight: 20, textAlign: 'center', - opacity: 0.9, }, - customInputWrapper: { - alignSelf: 'stretch', - borderRadius: 18, - padding: 1, - borderWidth: 1, - borderColor: 'rgba(255,255,255,0.1)', - backgroundColor: 'rgba(15,12,20,0.4)', - }, - customInput: { - minHeight: 55, - borderRadius: 16, - paddingHorizontal: 16, - paddingVertical: 12, - fontSize: 15, - color: Palette.white, - backgroundColor: 'rgba(15,12,20,0.85)', - fontFamily: FONT_FAMILY.InterRegular, + footer: { + width: '80%', + maxWidth: 520, + alignSelf: 'center', + paddingBottom: gutters * 2, }, }) + +export default PouchReady diff --git a/src/utils/createMusiclandPlaybackCover.native.js b/src/utils/createMusiclandPlaybackCover.native.js new file mode 100644 index 0000000..eb78f8f --- /dev/null +++ b/src/utils/createMusiclandPlaybackCover.native.js @@ -0,0 +1,3 @@ +export const createMusiclandPlaybackCover = async () => { + throw new Error('cover_client_rendering_is_web_only') +} diff --git a/src/utils/createMusiclandPlaybackCover.web.js b/src/utils/createMusiclandPlaybackCover.web.js new file mode 100644 index 0000000..2faf3cd --- /dev/null +++ b/src/utils/createMusiclandPlaybackCover.web.js @@ -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') + }) +} diff --git a/src/utils/projectStages.js b/src/utils/projectStages.js index fc0168d..3bdf6bf 100644 --- a/src/utils/projectStages.js +++ b/src/utils/projectStages.js @@ -136,7 +136,7 @@ const getStageDescription = (key, metadata) => { return 'Commencer la création de votre morceau' case 'director': if (!hasCover) { - return 'Générez une cover pour débloquer le playback' + return 'Validez votre pochette pour débloquer le playback' } if (isPlaybackGenerating) { return 'Votre playback est en cours de préparation' @@ -222,18 +222,17 @@ export const getStageAction = (key, project) => { switch (key) { case 'songwriter': return { - route: getLyricsCount(project) > 0 ? Routes.Lyrics : Routes.WritingLyrics, + route: getLyricsCount(project) > 0 ? Routes.Lyrics : Routes.CreateLyricsWithAi, } case 'beatmaker': { if (!project) { - return { route: Routes.Compose } + return { route: Routes.ComposeSong } } const musicStatus = project?.musicStatus || null const songUrl = project?.songUrl || null const coverStatus = project?.coverStatus || null const cover = project?.cover || {} - const hasCoverBackground = !!cover?.generatedBackground const hasCoverResult = !!cover?.result const hasFinalCover = !!project?.coverUrl @@ -245,26 +244,18 @@ export const getStageAction = (key, project) => { if (musicStatus === 'GENERATED') { return { route: Routes.SongReady } } - return { route: Routes.Compose } + return { route: Routes.ComposeSong } } if (coverStatus === 'GENERATING') { return { route: Routes.PouchReady } } - if (!hasCoverBackground) { + if (!hasCoverResult) { return { route: Routes.ChooseCoverType } } - if (!hasCoverResult) { - return { route: Routes.PouchReady } - } - - if (!hasFinalCover) { - return { route: Routes.ValidateCover } - } - - return { route: Routes.ChooseCoverType } + return { route: hasFinalCover ? Routes.ChooseCoverType : Routes.PouchReady } } case 'director': return { diff --git a/src/utils/registrationFlow.js b/src/utils/registrationFlow.js new file mode 100644 index 0000000..ba2d433 --- /dev/null +++ b/src/utils/registrationFlow.js @@ -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 } + ) +}