feat: fix clouds functions and heygen

This commit is contained in:
2026-09-04 12:22:29 +02:00
parent 7bf913e014
commit efbb7994f2
12 changed files with 276 additions and 75 deletions
+21
View File
@@ -0,0 +1,21 @@
# This file specifies files that are *not* uploaded to Google Cloud
# using gcloud. It follows the same syntax as .gitignore, with the addition of
# "#!include" directives (which insert the entries of the given .gitignore-style
# file at that point).
#
# For more information, run:
# $ gcloud topic gcloudignore
#
.gcloudignore
# If you would like to upload your .git directory, .gitignore file or files
# from your .gitignore file, remove the corresponding line
# below:
.git
.gitignore
node_modules
.env
.env.*
!.env.example
.secret.local
serviceAccountKey.json
+94 -34
View File
@@ -6,8 +6,64 @@ const { Buffer } = require('buffer')
const { setTimeout } = require('timers/promises') const { setTimeout } = require('timers/promises')
// --- CONFIGURATION --- // --- CONFIGURATION ---
// Plus intelligente que le Flash original, ultra rapide, et stable sur l'API. // Utilise des versions stables explicites en production. L'alias `latest` peut
const TEXT_MODEL_NAME = 'gemini-flash-latest' // changer sans déploiement et pointer temporairement vers un modèle saturé.
const TEXT_MODEL_NAMES = ['gemini-3.6-flash', 'gemini-3.5-flash', 'gemini-3.1-flash-lite']
const MODERATION_MODEL_NAMES = ['gemini-3.1-flash-lite', 'gemini-3.6-flash', 'gemini-3.5-flash']
const RETRYABLE_STATUS_CODES = new Set([404, 429, 500, 502, 503, 504])
const getErrorStatus = (error) => {
const status = Number(error?.status || error?.statusCode || error?.response?.status)
return Number.isFinite(status) ? status : null
}
const canTryFallbackModel = (error) => {
const status = getErrorStatus(error)
if (status && RETRYABLE_STATUS_CODES.has(status)) return true
const message = String(error?.message || '').toLowerCase()
return (
message.includes('high demand') ||
message.includes('overloaded') ||
message.includes('temporarily unavailable') ||
message.includes('model not found')
)
}
const generateWithModelFallback = async ({ ai, modelNames, label, request }) => {
let lastError = null
for (let index = 0; index < modelNames.length; index++) {
const modelName = modelNames[index]
try {
console.log(`🤖 [${label}] Modèle ${modelName} (${index + 1}/${modelNames.length})`)
const response = await ai.generate({
...request,
model: googleAI.model(modelName),
})
if (!response?.output) {
throw new Error(`${label}: réponse structurée vide pour ${modelName}.`)
}
return response.output
} catch (error) {
lastError = error
const hasFallback = index < modelNames.length - 1
if (!hasFallback || !canTryFallbackModel(error)) throw error
console.warn(`⚠️ [${label}] ${modelName} indisponible, essai du modèle suivant`, {
status: getErrorStatus(error),
message: error?.message,
})
await setTimeout(500 * (index + 1))
}
}
throw lastError || new Error(`${label}: aucun modèle disponible.`)
}
const IMAGE_MODEL_NAME = 'gemini-3-pro-image' const IMAGE_MODEL_NAME = 'gemini-3-pro-image'
// --- SINGLETON PATTERN (WARM START) --- // --- SINGLETON PATTERN (WARM START) ---
@@ -35,21 +91,23 @@ exports.generateAI = async ({ system = '', prompt = '', schema }) => {
throw new Error('Vous devez spécifier un prompt pour effectuer cette action.') throw new Error('Vous devez spécifier un prompt pour effectuer cette action.')
} }
console.log(`🧠 [generateAI] Start (${TEXT_MODEL_NAME})`) console.log(`🧠 [generateAI] Start (${TEXT_MODEL_NAMES.join(' -> ')})`)
const startedAt = Date.now() const startedAt = Date.now()
try { try {
const { output } = await ai.generate({ return await generateWithModelFallback({
model: googleAI.model(TEXT_MODEL_NAME), ai,
system, modelNames: TEXT_MODEL_NAMES,
prompt, label: 'generateAI',
output: { schema }, request: {
config: { system,
temperature: 0.7, // Créativité équilibrée prompt,
output: { schema },
config: {
temperature: 0.7, // Créativité équilibrée
},
}, },
}) })
return output
} catch (error) { } catch (error) {
console.error('❌ [generateAI] Error:', error.message) console.error('❌ [generateAI] Error:', error.message)
throw error throw error
@@ -129,34 +187,36 @@ ${lyricsText}
""" """
` `
console.log(`🛡️ [analyseLyrics] Start (${TEXT_MODEL_NAME})`) console.log(`🛡️ [analyseLyrics] Start (${MODERATION_MODEL_NAMES.join(' -> ')})`)
const startedAt = Date.now() const startedAt = Date.now()
try { try {
const { output } = await ai.generate({ const output = await generateWithModelFallback({
model: googleAI.model(TEXT_MODEL_NAME), ai,
system, modelNames: MODERATION_MODEL_NAMES,
prompt: userPrompt, label: 'analyseLyrics',
output: { schema: moderationSchema }, request: {
config: { system,
// Paramètres de sécurité permissifs pour laisser l'IA voir et juger le contenu prompt: userPrompt,
safetySettings: [ output: { schema: moderationSchema },
{ category: 'HARM_CATEGORY_HATE_SPEECH', threshold: 'BLOCK_NONE' }, config: {
{ // Paramètres de sécurité permissifs pour laisser l'IA voir et juger le contenu
category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', safetySettings: [
threshold: 'BLOCK_NONE', { category: 'HARM_CATEGORY_HATE_SPEECH', threshold: 'BLOCK_NONE' },
}, {
{ category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT',
category: 'HARM_CATEGORY_DANGEROUS_CONTENT', threshold: 'BLOCK_NONE',
threshold: 'BLOCK_NONE', },
}, {
{ category: 'HARM_CATEGORY_HARASSMENT', threshold: 'BLOCK_NONE' }, category: 'HARM_CATEGORY_DANGEROUS_CONTENT',
], threshold: 'BLOCK_NONE',
},
{ category: 'HARM_CATEGORY_HARASSMENT', threshold: 'BLOCK_NONE' },
],
},
}, },
}) })
if (!output) throw new Error("Échec de l'analyse de modération.")
// Correction de cohérence // Correction de cohérence
if (output.blocked) { if (output.blocked) {
output.flagged = true output.flagged = true
+16
View File
@@ -17,6 +17,7 @@ const projectsRef = db.collection('projects')
const REGION = process.env.FIREBASE_REGION || 'europe-west1' const REGION = process.env.FIREBASE_REGION || 'europe-west1'
const HEYGEN_BASE_URL = 'https://api.heygen.com/v3' const HEYGEN_BASE_URL = 'https://api.heygen.com/v3'
const HEYGEN_REQUEST_TIMEOUT_MS = 45000
const PLAYBACK_STATUS = { const PLAYBACK_STATUS = {
GENERATING: 'GENERATING', GENERATING: 'GENERATING',
DRAFT_READY: 'DRAFT_READY', DRAFT_READY: 'DRAFT_READY',
@@ -120,6 +121,7 @@ const findProjectForWebhook = async ({ callbackId, videoId }) => {
const fetchCanonicalVideo = async (videoId) => { const fetchCanonicalVideo = async (videoId) => {
const response = await axios.get(`${HEYGEN_BASE_URL}/videos/${videoId}`, { const response = await axios.get(`${HEYGEN_BASE_URL}/videos/${videoId}`, {
headers: getHeygenHeaders(), headers: getHeygenHeaders(),
timeout: HEYGEN_REQUEST_TIMEOUT_MS,
}) })
return response?.data?.data || null return response?.data?.data || null
@@ -176,6 +178,13 @@ exports.startPlaybackGeneration = onCall(
const callbackUrl = buildCallbackUrl() const callbackUrl = buildCallbackUrl()
const title = trimString(project?.title) || `Playback ${projectId}` const title = trimString(project?.title) || `Playback ${projectId}`
logger.info('[HeyGen] startPlaybackGeneration request', {
projectId,
uid,
hasSongUrl: Boolean(songUrl),
hasPhotoUrl: Boolean(photoUrl),
})
await projectRef.set( await projectRef.set(
{ {
playbackProvider: 'heygen', playbackProvider: 'heygen',
@@ -211,6 +220,7 @@ exports.startPlaybackGeneration = onCall(
}, },
{ {
headers: getHeygenHeaders(), headers: getHeygenHeaders(),
timeout: HEYGEN_REQUEST_TIMEOUT_MS,
} }
) )
@@ -227,6 +237,12 @@ exports.startPlaybackGeneration = onCall(
{ merge: true } { merge: true }
) )
logger.info('[HeyGen] generation accepted', {
projectId,
uid,
videoId,
})
return { return {
jobId: videoId, jobId: videoId,
callbackId, callbackId,
+3 -3
View File
@@ -204,9 +204,9 @@ exports.generateLyrics = onCall(LYRICS_FUNCTION_OPTIONS, async ({ auth = {}, dat
} }
} catch (moderationError) { } catch (moderationError) {
if (moderationError instanceof HttpsError) throw moderationError if (moderationError instanceof HttpsError) throw moderationError
console.error('⚠️ Moderation check error (fail open)', moderationError) console.error('⚠️ Moderation check error (fail closed)', moderationError)
// On continue si l'appel modération fail (fail open) ou on throw (fail closed) selon ta politique. // La modération essaie plusieurs modèles stables avant d'arriver ici.
// Ici je fail closed par sécurité pour une app publique. // Si tous sont indisponibles, on bloque par sécurité pour une app publique.
throw new HttpsError('internal', 'Vérification de sécurité indisponible.') throw new HttpsError('internal', 'Vérification de sécurité indisponible.')
} }
+19 -11
View File
@@ -2,7 +2,7 @@ const { onCall, onRequest, HttpsError } = require('firebase-functions/v2/https')
const axios = require('axios') const axios = require('axios')
const admin = require('firebase-admin') const admin = require('firebase-admin')
const { FieldValue } = require('firebase-admin/firestore') const { FieldValue } = require('firebase-admin/firestore')
const { logger } = require('firebase-functions/logger') const logger = require('firebase-functions/logger')
const { pipeline } = require('stream/promises') const { pipeline } = require('stream/promises')
const { randomUUID } = require('crypto') const { randomUUID } = require('crypto')
const { ALERT_TYPE, refList } = require('../index') const { ALERT_TYPE, refList } = require('../index')
@@ -22,6 +22,12 @@ const MUSIC_REFUND_SOURCE = 'music_generation_refund'
const SUNO_LYRICS_PROMPT_MAX_LENGTH = 5000 const SUNO_LYRICS_PROMPT_MAX_LENGTH = 5000
const SUNO_TITLE_MAX_LENGTH = 100 const SUNO_TITLE_MAX_LENGTH = 100
const getSunoAuthorizationHeader = () => {
const apiKey = String(SUNO_API_KEY.value() || '').trim()
if (!apiKey) throw new Error('SUNO_API_KEY_EMPTY')
return `Bearer ${apiKey}`
}
// ========================================== // ==========================================
// 1. DICTIONNAIRES DE TRADUCTION (FRONT -> SUNO) // 1. DICTIONNAIRES DE TRADUCTION (FRONT -> SUNO)
// ========================================== // ==========================================
@@ -409,14 +415,16 @@ async function markProjectMusicFailure(projectId, error) {
reason: sunoMessage, reason: sunoMessage,
}) })
} }
await docRef.set( const failureUpdate = {
{ musicStatus: 'FAILED',
musicStatus: 'FAILED', musicError: errorPayload,
musicError: errorPayload, updatedAt: FieldValue.serverTimestamp(),
updatedAt: FieldValue.serverTimestamp(), }
}, if (refundResult?.orderId) {
{ merge: true } failureUpdate.musicCreditsRefunded = true
) failureUpdate.musicCreditsRefundOrderId = refundResult.orderId
}
await docRef.set(failureUpdate, { merge: true })
// (Notification logic here...) // (Notification logic here...)
} catch (err) { } catch (err) {
console.error('Error marking failure', err) console.error('Error marking failure', err)
@@ -592,7 +600,7 @@ exports.generateMusic = onCall({ secrets: [SUNO_API_KEY] }, async ({ data = {} }
const response = await axios.post(`${SUNO_API_BASE}${SUNO_API_PATH}`, payload, { const response = await axios.post(`${SUNO_API_BASE}${SUNO_API_PATH}`, payload, {
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: `Bearer ${SUNO_API_KEY.value()}`, Authorization: getSunoAuthorizationHeader(),
}, },
}) })
@@ -626,7 +634,7 @@ exports.getSunoStatus = onCall({ secrets: [SUNO_API_KEY] }, async ({ data = {} }
if (!taskId) throw new Error('TaskId manquant') if (!taskId) throw new Error('TaskId manquant')
try { try {
const response = await axios.get(`${SUNO_API_BASE}${SUNO_STATUS_PATH}?taskId=${taskId}`, { const response = await axios.get(`${SUNO_API_BASE}${SUNO_STATUS_PATH}?taskId=${taskId}`, {
headers: { Authorization: `Bearer ${SUNO_API_KEY.value()}` }, headers: { Authorization: getSunoAuthorizationHeader() },
timeout: 30000, timeout: 30000,
}) })
return { return {
+9 -1
View File
@@ -113,10 +113,18 @@ const FullscreenIntroVideo = ({
const load = async () => { const load = async () => {
try { try {
const nextUri = await resolveMediaUri(url) const nextUri = await resolveMediaUri(url)
if (!nextUri) {
if (isMounted) {
setUri(null)
handleClose()
}
return
}
assignUri(nextUri) assignUri(nextUri)
} catch { } catch {
if (isMounted) { if (isMounted) {
setUri(null) setUri(null)
handleClose()
} }
} }
} }
@@ -126,7 +134,7 @@ const FullscreenIntroVideo = ({
return () => { return () => {
isMounted = false isMounted = false
} }
}, [url]) }, [handleClose, url])
useEffect(() => { useEffect(() => {
if (!visible || !uri) { if (!visible || !uri) {
+1 -1
View File
@@ -13,7 +13,7 @@ const emulatorHost = Platform.OS === 'web' ? 'localhost' : '192.168.1.60'
const USE_FUNCTIONS_EMULATOR = __DEV__ const USE_FUNCTIONS_EMULATOR = __DEV__
const configureFunctionsEmulator = (instance, regionKey = 'us-central1') => { const configureFunctionsEmulator = (instance, regionKey = 'us-central1') => {
const shouldUseEmulator = USE_FUNCTIONS_EMULATOR && instance?.useEmulator const shouldUseEmulator = false
if (!shouldUseEmulator || emulatorConfigured[regionKey]) { if (!shouldUseEmulator || emulatorConfigured[regionKey]) {
return return
+16 -6
View File
@@ -33,7 +33,8 @@ export function uploadFileToFirebase({
shouldCompress, shouldCompress,
fileType, fileType,
hasBlob: Boolean(providedBlob), hasBlob: Boolean(providedBlob),
uri, uriScheme: typeof uri === 'string' ? uri.split(':')[0] : null,
uriLength: typeof uri === 'string' ? uri.length : 0,
}) })
let workingURI = uri let workingURI = uri
let uploadBlob = providedBlob let uploadBlob = providedBlob
@@ -170,13 +171,22 @@ export function uploadFileToFirebase({
reject(error) reject(error)
}, },
async () => { async () => {
const url = await firebase.storage().ref(path).getDownloadURL() try {
const url = await firebase.storage().ref(path).getDownloadURL()
console.log('[uploadToFirebase] upload success (finalize)', { resultURI: url }) console.log('[uploadToFirebase] upload success (finalize)', { resultURI: url })
if (uploadBlob && typeof uploadBlob.close === 'function') { if (uploadBlob && typeof uploadBlob.close === 'function') {
uploadBlob.close() uploadBlob.close()
}
resolve({ resultURI: url })
} catch (error) {
console.log('[uploadToFirebase] download URL error', {
message: error?.message,
code: error?.code,
name: error?.name,
})
reject(error)
} }
resolve({ resultURI: url })
} }
) )
} catch (e) { } catch (e) {
+76 -16
View File
@@ -1,7 +1,7 @@
import * as ImagePicker from 'expo-image-picker' import * as ImagePicker from 'expo-image-picker'
import useMinuit from 'react-native-minuit/src/hooks/useMinuit.js' import useMinuit from 'react-native-minuit/src/hooks/useMinuit.js'
import React, { useMemo, useState } from 'react' import React, { useMemo, useState } from 'react'
import { Image, Text, View } from 'react-native' import { Image, Platform, Text, View } from 'react-native'
import { background } from '../../assets' import { background } from '../../assets'
import ActivityLoader from '../../components/ActivityLoader' import ActivityLoader from '../../components/ActivityLoader'
import BorderGradientButton from '../../components/BorderGradientButton' import BorderGradientButton from '../../components/BorderGradientButton'
@@ -19,21 +19,50 @@ import { PLAYBACK_AI_STATUS } from '../../utils/playbackAi'
import CreateLyricsHeader from '../Writing/components/CreateLyricsHeader' import CreateLyricsHeader from '../Writing/components/CreateLyricsHeader'
const FUNCTIONS_REGION = 'europe-west1' const FUNCTIONS_REGION = 'europe-west1'
const PHOTO_UPLOAD_TIMEOUT_MS = 120000
const HEYGEN_START_TIMEOUT_MS = 60000
const trimString = (value) => (typeof value === 'string' ? value.trim() : '') const trimString = (value) => (typeof value === 'string' ? value.trim() : '')
const isRemoteUrl = (value) => /^https?:\/\//i.test(trimString(value)) const isRemoteUrl = (value) => /^https?:\/\//i.test(trimString(value))
const resolveProject = ({ routeProject, selectedProject }) => { const resolveProject = ({ routeProject, selectedProject }) => {
if (routeProject?.id && selectedProject?.id === routeProject.id) { if (routeProject && typeof routeProject === 'object' && routeProject.id) {
if (selectedProject?.id === routeProject.id) {
return selectedProject
}
return routeProject
}
if (selectedProject?.id) {
return selectedProject return selectedProject
} }
return routeProject || null
return null
} }
const getInitialPhotoUri = ({ routePhotoUrl, project }) => const getInitialPhotoUri = ({ routePhotoUrl, project }) =>
trimString(routePhotoUrl) || trimString(project?.playbackPhotoUrl) || null trimString(routePhotoUrl) || trimString(project?.playbackPhotoUrl) || null
const getImageExtension = (fileName) => {
const normalizedFileName = trimString(fileName)
const extension = normalizedFileName.includes('.')
? normalizedFileName.split('.').pop().toLowerCase()
: ''
return /^[a-z0-9]+$/.test(extension) ? extension : 'jpg'
}
const withTimeout = (promise, timeoutMs, message) => {
let timeoutId
const timeoutPromise = new Promise((_, reject) => {
timeoutId = setTimeout(() => reject(new Error(message)), timeoutMs)
})
return Promise.race([promise, timeoutPromise]).finally(() => clearTimeout(timeoutId))
}
const callStartPlaybackGeneration = (payload) => { const callStartPlaybackGeneration = (payload) => {
const callable = getFunctionsClient(FUNCTIONS_REGION).httpsCallable('heygen-startPlaybackGeneration') const callable = getFunctionsClient(FUNCTIONS_REGION).httpsCallable('heygen-startPlaybackGeneration')
return callable(payload) return callable(payload)
@@ -49,6 +78,7 @@ const PlaybackAi = ({ route }) => {
project: resolveProject({ routeProject, selectedProject }), project: resolveProject({ routeProject, selectedProject }),
}) })
) )
const [selectedPhotoAsset, setSelectedPhotoAsset] = useState(null)
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const project = useMemo( const project = useMemo(
() => resolveProject({ routeProject, selectedProject }), () => resolveProject({ routeProject, selectedProject }),
@@ -64,9 +94,10 @@ const PlaybackAi = ({ route }) => {
quality: 1, quality: 1,
}) })
const nextUri = result?.assets?.[0]?.uri || null const asset = result?.assets?.[0]
if (nextUri) { if (asset?.uri) {
setSelectedPhotoUri(nextUri) setSelectedPhotoUri(asset.uri)
setSelectedPhotoAsset(asset)
} }
} catch (error) { } catch (error) {
setTooltip({ setTooltip({
@@ -77,6 +108,8 @@ const PlaybackAi = ({ route }) => {
} }
const handleGenerate = async () => { const handleGenerate = async () => {
let step = 'validation'
try { try {
if (!project?.id) { if (!project?.id) {
throw new Error('Projet introuvable') throw new Error('Projet introuvable')
@@ -94,24 +127,46 @@ const PlaybackAi = ({ route }) => {
let photoUrl = trimString(selectedPhotoUri) let photoUrl = trimString(selectedPhotoUri)
if (!isRemoteUrl(photoUrl)) { if (!isRemoteUrl(photoUrl)) {
const extension = trimString(selectedPhotoUri.split('.').pop()) || 'jpg' step = 'photo_upload'
const uploadResult = await uploadFileToFirebase({ const extension = getImageExtension(selectedPhotoAsset?.fileName)
uri: selectedPhotoUri, const path = `users/${currentUID}/projects/${project.id}/heygen/photo-${Date.now()}.${extension}`
path: `users/${currentUID}/projects/${project.id}/heygen/photo-${Date.now()}.${extension}`,
shouldCompress: true, console.log('[PlaybackAi] photo upload start', {
fileType: 'IMAGE', path,
fileSize: selectedPhotoAsset?.fileSize || null,
platform: Platform.OS,
}) })
const uploadResult = await withTimeout(
uploadFileToFirebase({
uri: selectedPhotoUri,
path,
shouldCompress: true,
fileType: 'IMAGE',
blob: Platform.OS === 'web' ? selectedPhotoAsset?.file : null,
}),
PHOTO_UPLOAD_TIMEOUT_MS,
"L'envoi de la photo a dépassé 2 minutes. Vérifie ta connexion puis réessaie."
)
photoUrl = trimString(uploadResult?.resultURI) photoUrl = trimString(uploadResult?.resultURI)
console.log('[PlaybackAi] photo upload complete', { path })
} }
if (!photoUrl) { if (!photoUrl) {
throw new Error("Impossible d'envoyer la photo vers Firebase") throw new Error("Impossible d'envoyer la photo vers Firebase")
} }
await callStartPlaybackGeneration({ step = 'heygen_start'
projectId: project.id, console.log('[PlaybackAi] HeyGen request start', { projectId: project.id })
photoUrl, await withTimeout(
}) callStartPlaybackGeneration({
projectId: project.id,
photoUrl,
}),
HEYGEN_START_TIMEOUT_MS,
'HeyGen ne répond pas après 1 minute. Réessaie dans quelques instants.'
)
console.log('[PlaybackAi] HeyGen request accepted', { projectId: project.id })
navigate(Routes.PlaybackAiStatus, { navigate(Routes.PlaybackAiStatus, {
project: { project: {
@@ -122,6 +177,11 @@ const PlaybackAi = ({ route }) => {
}, },
}) })
} catch (error) { } catch (error) {
console.error('[PlaybackAi] generation failed', {
step,
code: error?.code,
message: error?.message,
})
setTooltip({ setTooltip({
type: 'error', type: 'error',
text: error?.message || 'Erreur lors du lancement du playback IA', text: error?.message || 'Erreur lors du lancement du playback IA',
+2
View File
@@ -144,6 +144,8 @@ const ComposeSong = () => {
}, },
musicStatus: null, musicStatus: null,
sunoTaskId: firebase.firestore.FieldValue.delete(), sunoTaskId: firebase.firestore.FieldValue.delete(),
musicCreditsRefunded: firebase.firestore.FieldValue.delete(),
musicCreditsRefundOrderId: firebase.firestore.FieldValue.delete(),
} }
if (!isRegenerationFlow) { if (!isRegenerationFlow) {
+2
View File
@@ -200,6 +200,8 @@ const ComposeSong = () => {
}, },
musicStatus: null, musicStatus: null,
sunoTaskId: firebase.firestore.FieldValue.delete(), sunoTaskId: firebase.firestore.FieldValue.delete(),
musicCreditsRefunded: firebase.firestore.FieldValue.delete(),
musicCreditsRefundOrderId: firebase.firestore.FieldValue.delete(),
} }
if (!isRegenerationFlow) { if (!isRegenerationFlow) {
+17 -3
View File
@@ -297,6 +297,7 @@ const SongReady = () => {
onTogglePlayback={handleTogglePlayback} onTogglePlayback={handleTogglePlayback}
projectId={projectId} projectId={projectId}
selectedProject={selectedProject} selectedProject={selectedProject}
fallbackDurationS={selectedProject?.musicDurations?.[index]}
/> />
)} )}
contentContainerStyle={{ gap: 16, paddingBottom: gutters }} contentContainerStyle={{ gap: 16, paddingBottom: gutters }}
@@ -346,6 +347,7 @@ const SongOptionCard = ({
onTogglePlayback, onTogglePlayback,
projectId, projectId,
selectedProject, selectedProject,
fallbackDurationS,
}) => { }) => {
const trackTitle = const trackTitle =
(Array.isArray(selectedProject?.musicTitles) ? selectedProject.musicTitles?.[index] : null) || (Array.isArray(selectedProject?.musicTitles) ? selectedProject.musicTitles?.[index] : null) ||
@@ -360,9 +362,20 @@ const SongOptionCard = ({
metadata: { index, projectId }, metadata: { index, projectId },
}) })
const [progressInfo, setProgressInfo] = useState({ pos: 0, dur: 0 }) const fallbackDurationMs = Math.max(0, Math.round((Number(fallbackDurationS) || 0) * 1000))
const [progressInfo, setProgressInfo] = useState({
pos: 0,
dur: fallbackDurationMs,
})
const [isPlaying, setIsPlaying] = useState(false) const [isPlaying, setIsPlaying] = useState(false)
useEffect(() => {
if (!fallbackDurationMs) return
setProgressInfo((current) =>
current.dur ? current : { ...current, dur: fallbackDurationMs }
)
}, [fallbackDurationMs])
useEffect(() => { useEffect(() => {
registerPlayer(index, player) registerPlayer(index, player)
return () => registerPlayer(index, null) return () => registerPlayer(index, null)
@@ -387,7 +400,8 @@ const SongOptionCard = ({
return undefined return undefined
} }
const id = setInterval(() => { const id = setInterval(() => {
const durationMs = Math.max(0, Math.round((Number(player.duration) || 0) * 1000)) const playerDurationMs = Math.max(0, Math.round((Number(player.duration) || 0) * 1000))
const durationMs = playerDurationMs || fallbackDurationMs
const positionMs = Math.max(0, Math.round((Number(player.currentTime) || 0) * 1000)) const positionMs = Math.max(0, Math.round((Number(player.currentTime) || 0) * 1000))
setProgressInfo((prev) => { setProgressInfo((prev) => {
if ( if (
@@ -407,7 +421,7 @@ const SongOptionCard = ({
}) })
}, 300) }, 300)
return () => clearInterval(id) return () => clearInterval(id)
}, [player]) }, [fallbackDurationMs, player])
const handleSeek = useCallback( const handleSeek = useCallback(
async (targetMs) => { async (targetMs) => {