offset audio after playback and add songs
This commit is contained in:
@@ -92,7 +92,7 @@ android {
|
|||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 1
|
versionCode 1
|
||||||
versionName "2026.03.02"
|
versionName "2026.03.12"
|
||||||
}
|
}
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
debug {
|
debug {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
"deeplinks": [
|
"deeplinks": [
|
||||||
"musicland://"
|
"musicland://"
|
||||||
],
|
],
|
||||||
"version": "2026.03.02",
|
"version": "2026.03.12",
|
||||||
"icon": "./assets/icon.png",
|
"icon": "./assets/icon.png",
|
||||||
"backgroundColor": "#0F0C14",
|
"backgroundColor": "#0F0C14",
|
||||||
"jsEngine": "hermes",
|
"jsEngine": "hermes",
|
||||||
|
|||||||
+63
-80
@@ -1,5 +1,3 @@
|
|||||||
// functions/mergeVideoAndAudio.js (ou dans index.js)
|
|
||||||
|
|
||||||
const { onCall, HttpsError } = require('firebase-functions/v2/https')
|
const { onCall, HttpsError } = require('firebase-functions/v2/https')
|
||||||
const admin = require('firebase-admin')
|
const admin = require('firebase-admin')
|
||||||
const logger = require('firebase-functions/logger')
|
const logger = require('firebase-functions/logger')
|
||||||
@@ -19,33 +17,50 @@ ffmpeg.setFfmpegPath(ffmpegInstaller.path)
|
|||||||
|
|
||||||
const db = admin.firestore()
|
const db = admin.firestore()
|
||||||
const PLAYBACK_CODEC_TAG = 'h264-v1'
|
const PLAYBACK_CODEC_TAG = 'h264-v1'
|
||||||
|
const MAX_SYNC_OFFSET_SECONDS = 2
|
||||||
|
const SCALE_FILTER =
|
||||||
|
"scale='trunc(min(1920,iw)/2)*2':'trunc(min(1920,ih)/2)*2':force_original_aspect_ratio=decrease"
|
||||||
|
|
||||||
|
const toFiniteNumber = (value) => {
|
||||||
|
const numericValue = Number(value ?? 0)
|
||||||
|
return Number.isFinite(numericValue) ? numericValue : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const clampSyncOffsetSeconds = (value) => {
|
||||||
|
const safeValue = toFiniteNumber(value)
|
||||||
|
return Math.min(MAX_SYNC_OFFSET_SECONDS, Math.max(-MAX_SYNC_OFFSET_SECONDS, safeValue))
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatSecondsForFfmpeg = (value) => clampSyncOffsetSeconds(value).toFixed(3)
|
||||||
|
|
||||||
async function downloadToFile(url, destPath) {
|
async function downloadToFile(url, destPath) {
|
||||||
if (!/^https?:\/\//i.test(url || '')) {
|
if (!/^https?:\/\//i.test(url || '')) {
|
||||||
throw new HttpsError('invalid-argument', `URL non supportée: ${url}`)
|
throw new HttpsError('invalid-argument', `URL non supportée: ${url}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await axios.get(url, { responseType: 'arraybuffer' })
|
const res = await axios.get(url, { responseType: 'arraybuffer' })
|
||||||
await fs.writeFile(destPath, Buffer.from(res.data))
|
await fs.writeFile(destPath, Buffer.from(res.data))
|
||||||
return res.headers?.['content-type'] || ''
|
return res.headers?.['content-type'] || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
const SCALE_FILTER =
|
|
||||||
"scale='trunc(min(1920,iw)/2)*2':'trunc(min(1920,ih)/2)*2':force_original_aspect_ratio=decrease"
|
|
||||||
|
|
||||||
// functions/mergeVideoAndAudio.js
|
|
||||||
|
|
||||||
async function muxAudioIntoVideo({ videoPath, audioPath, outPath, syncOffset = 0 }) {
|
async function muxAudioIntoVideo({ videoPath, audioPath, outPath, syncOffset = 0 }) {
|
||||||
|
const safeSyncOffset = clampSyncOffsetSeconds(syncOffset)
|
||||||
|
const videoTrimSeconds = safeSyncOffset > 0 ? safeSyncOffset : 0
|
||||||
|
const audioTrimSeconds = safeSyncOffset < 0 ? Math.abs(safeSyncOffset) : 0
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let command = ffmpeg()
|
const command = ffmpeg()
|
||||||
|
|
||||||
// L'offset : si > 0, on retarde la vidéo. Si < 0, on retarde l'audio.
|
|
||||||
if (syncOffset > 0) command.inputOptions(['-itsoffset', String(syncOffset)])
|
|
||||||
command.input(videoPath)
|
command.input(videoPath)
|
||||||
|
if (videoTrimSeconds > 0) {
|
||||||
|
command.inputOptions(['-ss', formatSecondsForFfmpeg(videoTrimSeconds)])
|
||||||
|
}
|
||||||
|
|
||||||
if (syncOffset < 0) command.inputOptions(['-itsoffset', String(Math.abs(syncOffset))])
|
|
||||||
command.input(audioPath)
|
command.input(audioPath)
|
||||||
|
if (audioTrimSeconds > 0) {
|
||||||
|
command.inputOptions(['-ss', formatSecondsForFfmpeg(audioTrimSeconds)])
|
||||||
|
}
|
||||||
|
|
||||||
// Remplace le bloc .save(outPath) par celui-ci :
|
|
||||||
command
|
command
|
||||||
.outputOptions([
|
.outputOptions([
|
||||||
'-map',
|
'-map',
|
||||||
@@ -70,16 +85,20 @@ async function muxAudioIntoVideo({ videoPath, audioPath, outPath, syncOffset = 0
|
|||||||
'+faststart',
|
'+faststart',
|
||||||
'-shortest',
|
'-shortest',
|
||||||
])
|
])
|
||||||
.on('error', (err) => {
|
.on('error', (error) => {
|
||||||
console.error('FFmpeg Error:', err)
|
logger.error('FFmpeg Error', { error: error?.message || String(error) })
|
||||||
reject(err)
|
reject(error)
|
||||||
})
|
})
|
||||||
.on('end', () => {
|
.on('end', () => {
|
||||||
console.log('Processing finished !')
|
logger.info('FFmpeg processing finished', {
|
||||||
|
syncOffset: safeSyncOffset,
|
||||||
|
videoTrimSeconds,
|
||||||
|
audioTrimSeconds,
|
||||||
|
})
|
||||||
resolve()
|
resolve()
|
||||||
})
|
})
|
||||||
.output(outPath) // On définit la sortie ici
|
.output(outPath)
|
||||||
.run() // Et on lance l'exécution ici
|
.run()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,6 +107,7 @@ async function uploadPlaybackAsset({ videoUrl, audioUrl, storagePath, syncOffset
|
|||||||
const videoPath = path.join(tmpDir, 'video.mp4')
|
const videoPath = path.join(tmpDir, 'video.mp4')
|
||||||
const audioPath = path.join(tmpDir, 'audio.mp3')
|
const audioPath = path.join(tmpDir, 'audio.mp3')
|
||||||
const outPath = path.join(tmpDir, 'output.mp4')
|
const outPath = path.join(tmpDir, 'output.mp4')
|
||||||
|
const safeSyncOffset = clampSyncOffsetSeconds(syncOffset)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
logger.info('[merge] téléchargement des sources', { videoUrl, audioUrl })
|
logger.info('[merge] téléchargement des sources', { videoUrl, audioUrl })
|
||||||
@@ -95,8 +115,12 @@ async function uploadPlaybackAsset({ videoUrl, audioUrl, storagePath, syncOffset
|
|||||||
await downloadToFile(videoUrl, videoPath)
|
await downloadToFile(videoUrl, videoPath)
|
||||||
await downloadToFile(audioUrl, audioPath)
|
await downloadToFile(audioUrl, audioPath)
|
||||||
|
|
||||||
logger.info('[merge] transcodage/mux ffmpeg', { syncOffset })
|
logger.info('[merge] transcodage/mux ffmpeg', {
|
||||||
await muxAudioIntoVideo({ videoPath, audioPath, outPath, syncOffset })
|
syncOffset: safeSyncOffset,
|
||||||
|
videoTrimSeconds: safeSyncOffset > 0 ? safeSyncOffset : 0,
|
||||||
|
audioTrimSeconds: safeSyncOffset < 0 ? Math.abs(safeSyncOffset) : 0,
|
||||||
|
})
|
||||||
|
await muxAudioIntoVideo({ videoPath, audioPath, outPath, syncOffset: safeSyncOffset })
|
||||||
|
|
||||||
const bucket = admin.storage().bucket()
|
const bucket = admin.storage().bucket()
|
||||||
const downloadToken = crypto.randomUUID()
|
const downloadToken = crypto.randomUUID()
|
||||||
@@ -122,10 +146,10 @@ async function uploadPlaybackAsset({ videoUrl, audioUrl, storagePath, syncOffset
|
|||||||
contentType: 'video/mp4',
|
contentType: 'video/mp4',
|
||||||
storagePath,
|
storagePath,
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (error) {
|
||||||
logger.error('[merge] échec', { error: err?.message || String(err) })
|
logger.error('[merge] échec', { error: error?.message || String(error) })
|
||||||
if (err instanceof HttpsError) throw err
|
if (error instanceof HttpsError) throw error
|
||||||
throw new HttpsError('internal', err?.message || 'Fusion échouée')
|
throw new HttpsError('internal', error?.message || 'Fusion échouée')
|
||||||
} finally {
|
} finally {
|
||||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||||
}
|
}
|
||||||
@@ -133,6 +157,7 @@ async function uploadPlaybackAsset({ videoUrl, audioUrl, storagePath, syncOffset
|
|||||||
|
|
||||||
async function markPlaybackCompatibility({ projectId, initiatorUid = null }) {
|
async function markPlaybackCompatibility({ projectId, initiatorUid = null }) {
|
||||||
if (!projectId) return
|
if (!projectId) return
|
||||||
|
|
||||||
const docRef = db.collection('projects').doc(projectId)
|
const docRef = db.collection('projects').doc(projectId)
|
||||||
await docRef.set(
|
await docRef.set(
|
||||||
{
|
{
|
||||||
@@ -163,72 +188,26 @@ exports.mergeVideoAndAudio = onCall(
|
|||||||
throw new HttpsError('permission-denied', `storagePath doit commencer par ${expectedPrefix}`)
|
throw new HttpsError('permission-denied', `storagePath doit commencer par ${expectedPrefix}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await uploadPlaybackAsset({ videoUrl, audioUrl, storagePath, syncOffset })
|
logger.info('[merge] request received', {
|
||||||
if (projectId) {
|
projectId: projectId || null,
|
||||||
await markPlaybackCompatibility({ projectId, initiatorUid: uid })
|
initiator: uid,
|
||||||
}
|
syncOffset: clampSyncOffsetSeconds(syncOffset),
|
||||||
return result
|
})
|
||||||
}
|
|
||||||
)
|
|
||||||
/**
|
|
||||||
* Fusionne une vidéo et un audio avec correction de synchronisation.
|
|
||||||
* Gère les paramètres : videoUrl, audioUrl, storagePath, projectId, syncOffset
|
|
||||||
*/
|
|
||||||
exports.mergeVideoAndAudio = onCall(
|
|
||||||
{ region: REGION, timeoutSeconds: 540, memory: '1GiB' },
|
|
||||||
async (request) => {
|
|
||||||
// Dans v2, les arguments sont dans request.data
|
|
||||||
const { data, auth } = request;
|
|
||||||
const uid = auth?.uid;
|
|
||||||
|
|
||||||
if (!uid) {
|
|
||||||
throw new HttpsError('unauthenticated', 'Authentification requise');
|
|
||||||
}
|
|
||||||
|
|
||||||
const { videoUrl, audioUrl, storagePath, projectId, syncOffset } = data || {};
|
|
||||||
|
|
||||||
// 1. Validation des paramètres
|
|
||||||
if (!videoUrl || !audioUrl || !storagePath) {
|
|
||||||
throw new HttpsError(
|
|
||||||
'invalid-argument',
|
|
||||||
'Paramètres manquants : videoUrl, audioUrl et storagePath sont requis.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Sécurité : Vérifier que l'utilisateur écrit dans son propre dossier
|
|
||||||
const expectedPrefix = `users/${uid}/`;
|
|
||||||
if (!storagePath.startsWith(expectedPrefix)) {
|
|
||||||
throw new HttpsError(
|
|
||||||
'permission-denied',
|
|
||||||
`Accès refusé : le chemin doit commencer par ${expectedPrefix}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
logger.info(`[merge] Début du traitement pour le projet : ${projectId || 'inconnu'}`);
|
|
||||||
|
|
||||||
// 3. Appel de la logique de traitement (download -> ffmpeg -> upload)
|
|
||||||
// On passe le syncOffset s'il existe (ex: -0.150 pour 150ms de latence)
|
|
||||||
const result = await uploadPlaybackAsset({
|
const result = await uploadPlaybackAsset({
|
||||||
videoUrl,
|
videoUrl,
|
||||||
audioUrl,
|
audioUrl,
|
||||||
storagePath,
|
storagePath,
|
||||||
syncOffset: syncOffset || 0
|
syncOffset: clampSyncOffsetSeconds(syncOffset),
|
||||||
});
|
})
|
||||||
|
|
||||||
// 4. Marquer la compatibilité dans Firestore si un ID de projet est fourni
|
|
||||||
if (projectId) {
|
if (projectId) {
|
||||||
await markPlaybackCompatibility({ projectId, initiatorUid: uid });
|
await markPlaybackCompatibility({ projectId, initiatorUid: uid })
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result
|
||||||
} catch (error) {
|
|
||||||
logger.error('[merge] Erreur fatale lors de la fusion', error);
|
|
||||||
if (error instanceof HttpsError) throw error;
|
|
||||||
throw new HttpsError('internal', error.message || 'Erreur interne de fusion');
|
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
);
|
|
||||||
|
|
||||||
exports.reencodePlayback = onCall(
|
exports.reencodePlayback = onCall(
|
||||||
{ region: REGION, timeoutSeconds: 540, memory: '1GiB' },
|
{ region: REGION, timeoutSeconds: 540, memory: '1GiB' },
|
||||||
@@ -245,6 +224,7 @@ exports.reencodePlayback = onCall(
|
|||||||
projectId,
|
projectId,
|
||||||
initiator: uid,
|
initiator: uid,
|
||||||
})
|
})
|
||||||
|
|
||||||
const projectRef = db.collection('projects').doc(projectId)
|
const projectRef = db.collection('projects').doc(projectId)
|
||||||
const projectSnap = await projectRef.get()
|
const projectSnap = await projectRef.get()
|
||||||
if (!projectSnap.exists) {
|
if (!projectSnap.exists) {
|
||||||
@@ -254,6 +234,7 @@ exports.reencodePlayback = onCall(
|
|||||||
})
|
})
|
||||||
throw new HttpsError('not-found', 'Projet introuvable')
|
throw new HttpsError('not-found', 'Projet introuvable')
|
||||||
}
|
}
|
||||||
|
|
||||||
const project = projectSnap.data() || {}
|
const project = projectSnap.data() || {}
|
||||||
const videoUrl = project.playbackUrl
|
const videoUrl = project.playbackUrl
|
||||||
const audioUrl = project.songUrl
|
const audioUrl = project.songUrl
|
||||||
@@ -279,12 +260,14 @@ exports.reencodePlayback = onCall(
|
|||||||
},
|
},
|
||||||
{ merge: true }
|
{ merge: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
await markPlaybackCompatibility({ projectId, initiatorUid: uid })
|
await markPlaybackCompatibility({ projectId, initiatorUid: uid })
|
||||||
logger.info('[reencodePlayback] success', {
|
logger.info('[reencodePlayback] success', {
|
||||||
projectId,
|
projectId,
|
||||||
initiator: uid,
|
initiator: uid,
|
||||||
storagePath,
|
storagePath,
|
||||||
})
|
})
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -122,6 +122,14 @@
|
|||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"expo": {
|
"expo": {
|
||||||
|
"autolinking": {
|
||||||
|
"android": {
|
||||||
|
"exclude": [
|
||||||
|
"expo-minuit-shake-report",
|
||||||
|
"expo-sensors"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"doctor": {
|
"doctor": {
|
||||||
"reactNativeDirectoryCheck": {
|
"reactNativeDirectoryCheck": {
|
||||||
"exclude": [
|
"exclude": [
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export default ({
|
|||||||
maxValue,
|
maxValue,
|
||||||
progress,
|
progress,
|
||||||
onSeek,
|
onSeek,
|
||||||
|
onValueChange,
|
||||||
onSeekStart,
|
onSeekStart,
|
||||||
onSeekEnd,
|
onSeekEnd,
|
||||||
seekEnabled = false,
|
seekEnabled = false,
|
||||||
@@ -42,9 +43,13 @@ export default ({
|
|||||||
const handleValueChange = useCallback(
|
const handleValueChange = useCallback(
|
||||||
(nextValue) => {
|
(nextValue) => {
|
||||||
if (!seekEnabled) return
|
if (!seekEnabled) return
|
||||||
setSliderValue(clamp01(nextValue))
|
const ratio = clamp01(nextValue)
|
||||||
|
setSliderValue(ratio)
|
||||||
|
if (typeof onValueChange === 'function') {
|
||||||
|
onValueChange(ratio)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[seekEnabled]
|
[onValueChange, seekEnabled]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleSlidingComplete = useCallback(
|
const handleSlidingComplete = useCallback(
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const Slider = ({
|
|||||||
maxValue,
|
maxValue,
|
||||||
progress,
|
progress,
|
||||||
onSeek,
|
onSeek,
|
||||||
|
onValueChange,
|
||||||
onSeekStart,
|
onSeekStart,
|
||||||
onSeekEnd,
|
onSeekEnd,
|
||||||
seekEnabled = false,
|
seekEnabled = false,
|
||||||
@@ -34,11 +35,12 @@ const Slider = ({
|
|||||||
const available = Math.max(layoutWidth - INITIAL_BOX_SIZE, 0)
|
const available = Math.max(layoutWidth - INITIAL_BOX_SIZE, 0)
|
||||||
if (available <= 0) {
|
if (available <= 0) {
|
||||||
setRatio(0)
|
setRatio(0)
|
||||||
return
|
return 0
|
||||||
}
|
}
|
||||||
const clampedX = Math.min(Math.max(x, 0), layoutWidth)
|
const clampedX = Math.min(Math.max(x, 0), layoutWidth)
|
||||||
const nextRatio = clamp01(clampedX / available)
|
const nextRatio = clamp01(clampedX / available)
|
||||||
setRatio(nextRatio)
|
setRatio(nextRatio)
|
||||||
|
return nextRatio
|
||||||
},
|
},
|
||||||
[layoutWidth]
|
[layoutWidth]
|
||||||
)
|
)
|
||||||
@@ -47,20 +49,26 @@ const Slider = ({
|
|||||||
(event) => {
|
(event) => {
|
||||||
if (!seekEnabled) return
|
if (!seekEnabled) return
|
||||||
draggingRef.current = true
|
draggingRef.current = true
|
||||||
updateRatioFromX(event?.nativeEvent?.locationX || 0)
|
const nextRatio = updateRatioFromX(event?.nativeEvent?.locationX || 0)
|
||||||
|
if (typeof nextRatio === 'number' && typeof onValueChange === 'function') {
|
||||||
|
onValueChange(nextRatio)
|
||||||
|
}
|
||||||
if (typeof onSeekStart === 'function') {
|
if (typeof onSeekStart === 'function') {
|
||||||
onSeekStart()
|
onSeekStart()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[seekEnabled, updateRatioFromX, onSeekStart]
|
[seekEnabled, updateRatioFromX, onSeekStart, onValueChange]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleMove = useCallback(
|
const handleMove = useCallback(
|
||||||
(event) => {
|
(event) => {
|
||||||
if (!seekEnabled || !draggingRef.current) return
|
if (!seekEnabled || !draggingRef.current) return
|
||||||
updateRatioFromX(event?.nativeEvent?.locationX || 0)
|
const nextRatio = updateRatioFromX(event?.nativeEvent?.locationX || 0)
|
||||||
|
if (typeof nextRatio === 'number' && typeof onValueChange === 'function') {
|
||||||
|
onValueChange(nextRatio)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[seekEnabled, updateRatioFromX]
|
[seekEnabled, updateRatioFromX, onValueChange]
|
||||||
)
|
)
|
||||||
|
|
||||||
const finishSeeking = useCallback(() => {
|
const finishSeeking = useCallback(() => {
|
||||||
|
|||||||
@@ -0,0 +1,314 @@
|
|||||||
|
import { Image as ExpoImage } from 'expo-image'
|
||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { Image, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'
|
||||||
|
import { SheetManager } from 'react-native-actions-sheet'
|
||||||
|
import useMinuit from 'react-native-minuit/src/hooks/useMinuit'
|
||||||
|
import { icons, img } from '../../assets'
|
||||||
|
import usePlaylistMusicSearch from '../../hooks/usePlaylistMusicSearch'
|
||||||
|
import { isWeb } from '../../hooks/useLayoutType'
|
||||||
|
import { addMusicToPlaylist } from '../../screens/Library/Playlists/playlist'
|
||||||
|
import { Palette, Style } from '../../styles'
|
||||||
|
import { FONT_FAMILY } from '../../styles/Fonts'
|
||||||
|
import { size } from '../../styles/Style'
|
||||||
|
import AppActionSheet from '../AppActionSheet'
|
||||||
|
import SearchBar from '../SearchBar'
|
||||||
|
|
||||||
|
const SHEET_ID = 'PlaylistAddTracks'
|
||||||
|
|
||||||
|
const resolveCoverUri = (project) => {
|
||||||
|
const isValid = (value) => typeof value === 'string' && value.trim().length > 0
|
||||||
|
if (!project) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isValid(project?.coverUrl)) {
|
||||||
|
return project.coverUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
const cover = project?.cover
|
||||||
|
if (!cover || typeof cover !== 'object') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const coverCandidates = [cover?.result, cover?.finalUrl, cover?.generatedBackground]
|
||||||
|
|
||||||
|
if (Array.isArray(cover?.options)) {
|
||||||
|
coverCandidates.push(
|
||||||
|
...cover.options.flatMap((option) => [option?.finalUrl, option?.generatedUrl])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return coverCandidates.find(isValid) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
const PlaylistAddTracksModal = (props) => {
|
||||||
|
const { setTooltip } = useMinuit()
|
||||||
|
const { search, setSearch, musics = [], loading, hasSearch } = usePlaylistMusicSearch()
|
||||||
|
const [addedMusicIds, setAddedMusicIds] = useState([])
|
||||||
|
const [pendingMusicIds, setPendingMusicIds] = useState([])
|
||||||
|
const [webVisible, setWebVisible] = useState(true)
|
||||||
|
|
||||||
|
const playlistId = props?.payload?.playlistId
|
||||||
|
const existingMusicIds = useMemo(() => {
|
||||||
|
if (!Array.isArray(props?.payload?.existingMusicIds)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return props.payload.existingMusicIds.filter((item) => typeof item === 'string')
|
||||||
|
}, [props?.payload?.existingMusicIds])
|
||||||
|
|
||||||
|
const addedMusicIdSet = useMemo(() => new Set(addedMusicIds), [addedMusicIds])
|
||||||
|
const pendingMusicIdSet = useMemo(() => new Set(pendingMusicIds), [pendingMusicIds])
|
||||||
|
const existingMusicIdSet = useMemo(() => new Set(existingMusicIds), [existingMusicIds])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setWebVisible(true)
|
||||||
|
setSearch('')
|
||||||
|
setAddedMusicIds([])
|
||||||
|
setPendingMusicIds([])
|
||||||
|
}, [props?.payload, setSearch])
|
||||||
|
|
||||||
|
const hideSheet = useCallback(() => {
|
||||||
|
if (isWeb) {
|
||||||
|
setWebVisible(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return Promise.resolve(SheetManager.hide(SHEET_ID))
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[PlaylistAddTracks] hide error', error?.message || error)
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleAddTrack = useCallback(
|
||||||
|
async (project) => {
|
||||||
|
const projectId = project?.id
|
||||||
|
if (typeof playlistId !== 'string' || typeof projectId !== 'string') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (existingMusicIdSet.has(projectId) || addedMusicIdSet.has(projectId)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (pendingMusicIdSet.has(projectId)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setPendingMusicIds((previous) => [...previous, projectId])
|
||||||
|
|
||||||
|
try {
|
||||||
|
await addMusicToPlaylist({ playlistId, projectId })
|
||||||
|
setAddedMusicIds((previous) =>
|
||||||
|
previous.includes(projectId) ? previous : [...previous, projectId]
|
||||||
|
)
|
||||||
|
setTooltip({
|
||||||
|
type: 'success',
|
||||||
|
text: 'Morceau ajouté à la playlist',
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.log('PlaylistAddTracksModal add track error', error?.message || error)
|
||||||
|
setTooltip({
|
||||||
|
type: 'error',
|
||||||
|
text: error?.message || 'Ajout impossible',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setPendingMusicIds((previous) => previous.filter((item) => item !== projectId))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[addedMusicIdSet, existingMusicIdSet, pendingMusicIdSet, playlistId, setTooltip]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isWeb && !webVisible) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppActionSheet id={SHEET_ID} webModal={isWeb} onClose={hideSheet}>
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.headerRow}>
|
||||||
|
<View style={{ flex: 1, gap: 6 }}>
|
||||||
|
<Text style={styles.title}>Ajouter des morceaux</Text>
|
||||||
|
<Text style={styles.description}>
|
||||||
|
Recherche un morceau puis ajoute-le directement dans cette playlist.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Pressable onPress={hideSheet} hitSlop={12} style={styles.closeButton}>
|
||||||
|
<Image source={icons.close} style={size({ size: 16 })} resizeMode="contain" />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<SearchBar
|
||||||
|
placeholder="Rechercher un morceau"
|
||||||
|
textInputProps={{
|
||||||
|
value: search,
|
||||||
|
onChangeText: setSearch,
|
||||||
|
autoFocus: true,
|
||||||
|
autoCorrect: false,
|
||||||
|
autoCapitalize: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
style={styles.resultsContainer}
|
||||||
|
contentContainerStyle={styles.resultsContent}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{!hasSearch ? (
|
||||||
|
<Text style={styles.emptyText}>Commence par rechercher un titre ou un artiste.</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{hasSearch && loading ? <Text style={styles.emptyText}>Chargement…</Text> : null}
|
||||||
|
|
||||||
|
{hasSearch && !loading && musics.length === 0 ? (
|
||||||
|
<Text style={styles.emptyText}>Aucun morceau trouvé.</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{hasSearch &&
|
||||||
|
musics.map((music) => {
|
||||||
|
const projectId = music?.id
|
||||||
|
const isExisting = typeof projectId === 'string' && existingMusicIdSet.has(projectId)
|
||||||
|
const isAdded = typeof projectId === 'string' && addedMusicIdSet.has(projectId)
|
||||||
|
const isPending = typeof projectId === 'string' && pendingMusicIdSet.has(projectId)
|
||||||
|
const isDisabled = isExisting || isAdded || isPending
|
||||||
|
const coverUri = resolveCoverUri(music)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View key={projectId || music?.objectID} style={styles.resultCard}>
|
||||||
|
{coverUri ? (
|
||||||
|
<ExpoImage
|
||||||
|
source={{ uri: coverUri }}
|
||||||
|
cachePolicy="memory-disk"
|
||||||
|
priority="high"
|
||||||
|
contentFit="cover"
|
||||||
|
transition={100}
|
||||||
|
style={styles.cover}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Image source={img.placeholder} style={styles.cover} resizeMode="cover" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={styles.resultTextContainer}>
|
||||||
|
<Text numberOfLines={2} style={styles.resultTitle}>
|
||||||
|
{music?.title || 'Sans titre'}
|
||||||
|
</Text>
|
||||||
|
<Text numberOfLines={1} style={styles.resultSubtitle}>
|
||||||
|
{music?.userName || 'MusicLand'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
onPress={() => handleAddTrack(music)}
|
||||||
|
disabled={isDisabled}
|
||||||
|
style={[
|
||||||
|
styles.actionButton,
|
||||||
|
isDisabled ? styles.actionButtonDisabled : styles.actionButtonEnabled,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={styles.actionButtonText}>
|
||||||
|
{isPending ? 'Ajout...' : isDisabled ? 'Ajouté' : 'Ajouter'}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</AppActionSheet>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PlaylistAddTracksModal
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
gap: 18,
|
||||||
|
},
|
||||||
|
headerRow: {
|
||||||
|
...Style.containerRow,
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 22,
|
||||||
|
color: Palette.white,
|
||||||
|
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 20,
|
||||||
|
color: Palette.white,
|
||||||
|
opacity: 0.82,
|
||||||
|
fontFamily: FONT_FAMILY.InterRegular,
|
||||||
|
},
|
||||||
|
closeButton: {
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 16,
|
||||||
|
backgroundColor: Palette.ultraLightWhite,
|
||||||
|
...Style.containerCenter,
|
||||||
|
},
|
||||||
|
resultsContainer: {
|
||||||
|
maxHeight: 420,
|
||||||
|
},
|
||||||
|
resultsContent: {
|
||||||
|
gap: 10,
|
||||||
|
paddingVertical: 2,
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
textAlign: 'center',
|
||||||
|
color: Palette.white,
|
||||||
|
opacity: 0.82,
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 20,
|
||||||
|
fontFamily: FONT_FAMILY.InterRegular,
|
||||||
|
paddingVertical: 24,
|
||||||
|
},
|
||||||
|
resultCard: {
|
||||||
|
...Style.containerRow,
|
||||||
|
gap: 12,
|
||||||
|
padding: 12,
|
||||||
|
borderRadius: 16,
|
||||||
|
backgroundColor: Palette.ultraLightWhite,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
cover: {
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
borderRadius: 14,
|
||||||
|
},
|
||||||
|
resultTextContainer: {
|
||||||
|
flex: 1,
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
|
resultTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
lineHeight: 18,
|
||||||
|
color: Palette.white,
|
||||||
|
fontFamily: FONT_FAMILY.OwnersRegular,
|
||||||
|
},
|
||||||
|
resultSubtitle: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: Palette.white,
|
||||||
|
opacity: 0.85,
|
||||||
|
fontFamily: FONT_FAMILY.InterRegular,
|
||||||
|
},
|
||||||
|
actionButton: {
|
||||||
|
minWidth: 88,
|
||||||
|
height: 38,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
...Style.containerCenter,
|
||||||
|
},
|
||||||
|
actionButtonEnabled: {
|
||||||
|
backgroundColor: '#F94697',
|
||||||
|
},
|
||||||
|
actionButtonDisabled: {
|
||||||
|
backgroundColor: Palette.glass,
|
||||||
|
},
|
||||||
|
actionButtonText: {
|
||||||
|
color: Palette.white,
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -4,9 +4,8 @@ import { SheetManager } from 'react-native-actions-sheet'
|
|||||||
import useMinuit from 'react-native-minuit/src/hooks/useMinuit'
|
import useMinuit from 'react-native-minuit/src/hooks/useMinuit'
|
||||||
import SwiperFlatList from 'react-native-swiper-flatlist'
|
import SwiperFlatList from 'react-native-swiper-flatlist'
|
||||||
import { icons } from '../../assets'
|
import { icons } from '../../assets'
|
||||||
import { arrayUnion, playlistsRef } from '../../config/firebase'
|
|
||||||
import { useUserData } from '../../providers/UserDataProvider'
|
import { useUserData } from '../../providers/UserDataProvider'
|
||||||
import { createPlaylist } from '../../screens/Library/Playlists/playlist'
|
import { addMusicToPlaylist, createPlaylist } from '../../screens/Library/Playlists/playlist'
|
||||||
import { Palette } from '../../styles'
|
import { Palette } from '../../styles'
|
||||||
import { FONT_FAMILY } from '../../styles/Fonts'
|
import { FONT_FAMILY } from '../../styles/Fonts'
|
||||||
import Style, { size } from '../../styles/Style'
|
import Style, { size } from '../../styles/Style'
|
||||||
@@ -112,9 +111,9 @@ const PlaylistModal = (props) => {
|
|||||||
const projectId = props?.payload?.projectId
|
const projectId = props?.payload?.projectId
|
||||||
if (!selectedId) return
|
if (!selectedId) return
|
||||||
if (projectId) {
|
if (projectId) {
|
||||||
await playlistsRef.doc(selectedId).update({
|
await addMusicToPlaylist({
|
||||||
musics: arrayUnion(projectId),
|
playlistId: selectedId,
|
||||||
updatedAt: new Date(),
|
projectId,
|
||||||
})
|
})
|
||||||
const addedPlaylist = sanitizedPlaylists.find((p) => p?.id === selectedId)
|
const addedPlaylist = sanitizedPlaylists.find((p) => p?.id === selectedId)
|
||||||
const name = addedPlaylist?.name || 'la playlist'
|
const name = addedPlaylist?.name || 'la playlist'
|
||||||
|
|||||||
@@ -6,9 +6,8 @@ import { SheetManager, useProviderContext } from 'react-native-actions-sheet'
|
|||||||
import { actionSheetEventManager } from 'react-native-actions-sheet/dist/src/eventmanager'
|
import { actionSheetEventManager } from 'react-native-actions-sheet/dist/src/eventmanager'
|
||||||
import useMinuit from 'react-native-minuit/src/hooks/useMinuit'
|
import useMinuit from 'react-native-minuit/src/hooks/useMinuit'
|
||||||
import { icons } from '../../assets'
|
import { icons } from '../../assets'
|
||||||
import { arrayUnion, playlistsRef } from '../../config/firebase'
|
|
||||||
import { useUserData } from '../../providers/UserDataProvider'
|
import { useUserData } from '../../providers/UserDataProvider'
|
||||||
import { createPlaylist } from '../../screens/Library/Playlists/playlist'
|
import { addMusicToPlaylist, createPlaylist } from '../../screens/Library/Playlists/playlist'
|
||||||
import { Palette } from '../../styles'
|
import { Palette } from '../../styles'
|
||||||
import { FONT_FAMILY } from '../../styles/Fonts'
|
import { FONT_FAMILY } from '../../styles/Fonts'
|
||||||
import Style, { size } from '../../styles/Style'
|
import Style, { size } from '../../styles/Style'
|
||||||
@@ -64,9 +63,9 @@ const PlaylistModal = (props) => {
|
|||||||
try {
|
try {
|
||||||
const projectId = props?.payload?.projectId
|
const projectId = props?.payload?.projectId
|
||||||
if (projectId) {
|
if (projectId) {
|
||||||
await playlistsRef.doc(selectedId).update({
|
await addMusicToPlaylist({
|
||||||
musics: arrayUnion(projectId),
|
playlistId: selectedId,
|
||||||
updatedAt: new Date(),
|
projectId,
|
||||||
})
|
})
|
||||||
const addedPlaylist = sanitizedPlaylists.find((p) => p?.id === selectedId)
|
const addedPlaylist = sanitizedPlaylists.find((p) => p?.id === selectedId)
|
||||||
const name = addedPlaylist?.name || 'la playlist'
|
const name = addedPlaylist?.name || 'la playlist'
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import React, { useCallback, useMemo } from 'react'
|
||||||
|
import { StyleSheet, Text, View } from 'react-native'
|
||||||
|
import Slider from '../Slider'
|
||||||
|
import { Palette } from '../../styles'
|
||||||
|
import { FONT_FAMILY } from '../../styles/Fonts'
|
||||||
|
import {
|
||||||
|
MAX_SYNC_OFFSET_MS,
|
||||||
|
clampSyncOffsetMs,
|
||||||
|
formatSyncOffsetMs,
|
||||||
|
snapSyncOffsetMs,
|
||||||
|
} from '../../utils/playbackSync'
|
||||||
|
|
||||||
|
const clamp01 = (value) => Math.min(1, Math.max(0, Number(value) || 0))
|
||||||
|
|
||||||
|
const SyncOffsetSlider = ({
|
||||||
|
valueMs = 0,
|
||||||
|
onChange,
|
||||||
|
disabled = false,
|
||||||
|
title = 'Ajuster la synchro',
|
||||||
|
}) => {
|
||||||
|
const safeValueMs = useMemo(() => snapSyncOffsetMs(valueMs), [valueMs])
|
||||||
|
|
||||||
|
const progress = useMemo(() => {
|
||||||
|
return clamp01((safeValueMs + MAX_SYNC_OFFSET_MS) / (MAX_SYNC_OFFSET_MS * 2))
|
||||||
|
}, [safeValueMs])
|
||||||
|
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(ratio) => {
|
||||||
|
if (disabled || typeof onChange !== 'function') return
|
||||||
|
const clampedRatio = clamp01(ratio)
|
||||||
|
const rawValueMs = clampedRatio * (MAX_SYNC_OFFSET_MS * 2) - MAX_SYNC_OFFSET_MS
|
||||||
|
onChange(snapSyncOffsetMs(rawValueMs))
|
||||||
|
},
|
||||||
|
[disabled, onChange]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.title}>{title}</Text>
|
||||||
|
<Text style={styles.value}>{formatSyncOffsetMs(clampSyncOffsetMs(safeValueMs))}</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.caption}>Gauche = audio en avance. Droite = audio en retard.</Text>
|
||||||
|
<Slider
|
||||||
|
value="Audio en avance"
|
||||||
|
maxValue="Audio en retard"
|
||||||
|
progress={progress}
|
||||||
|
seekEnabled={!disabled}
|
||||||
|
onValueChange={handleChange}
|
||||||
|
onSeek={handleChange}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
width: '100%',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
color: Palette.white,
|
||||||
|
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||||
|
fontSize: 15,
|
||||||
|
},
|
||||||
|
value: {
|
||||||
|
color: '#F94697',
|
||||||
|
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||||
|
fontSize: 15,
|
||||||
|
},
|
||||||
|
caption: {
|
||||||
|
color: Palette.white,
|
||||||
|
opacity: 0.8,
|
||||||
|
fontFamily: FONT_FAMILY.InterRegular,
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export default SyncOffsetSlider
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { projectsRef } from '../config/firebase'
|
||||||
|
|
||||||
|
const isValidUri = (value) => typeof value === 'string' && value.trim().length > 0
|
||||||
|
|
||||||
|
export const hasCoverAsset = (project) => {
|
||||||
|
if (!project) return false
|
||||||
|
if (isValidUri(project?.coverUrl)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const cover = project?.cover
|
||||||
|
if (!cover || typeof cover !== 'object') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const coverCandidates = [cover?.result, cover?.finalUrl, cover?.generatedBackground]
|
||||||
|
if (Array.isArray(cover?.options)) {
|
||||||
|
coverCandidates.push(
|
||||||
|
...cover.options.flatMap((option) => [option?.finalUrl, option?.generatedUrl])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return coverCandidates.some(isValidUri)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const hasThumbnailAsset = (project) =>
|
||||||
|
isValidUri(project?.thumbnailUrl) || isValidUri(project?.songThumbnailUrl)
|
||||||
|
|
||||||
|
const mergeProjectAssets = (
|
||||||
|
project,
|
||||||
|
firestoreData,
|
||||||
|
{ mergeCover = false, mergeThumbnails = false } = {}
|
||||||
|
) => {
|
||||||
|
if (!firestoreData || typeof firestoreData !== 'object') {
|
||||||
|
return project
|
||||||
|
}
|
||||||
|
const mergedProject = { ...project }
|
||||||
|
|
||||||
|
if (mergeCover) {
|
||||||
|
if (!isValidUri(mergedProject.coverUrl) && isValidUri(firestoreData.coverUrl)) {
|
||||||
|
mergedProject.coverUrl = firestoreData.coverUrl
|
||||||
|
}
|
||||||
|
const firestoreCover =
|
||||||
|
firestoreData.cover && typeof firestoreData.cover === 'object' ? firestoreData.cover : null
|
||||||
|
if (firestoreCover) {
|
||||||
|
mergedProject.cover = {
|
||||||
|
...(typeof mergedProject.cover === 'object' ? mergedProject.cover : {}),
|
||||||
|
...firestoreCover,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mergeThumbnails) {
|
||||||
|
if (!isValidUri(mergedProject.thumbnailUrl) && isValidUri(firestoreData.thumbnailUrl)) {
|
||||||
|
mergedProject.thumbnailUrl = firestoreData.thumbnailUrl
|
||||||
|
}
|
||||||
|
if (!isValidUri(mergedProject.songThumbnailUrl) && isValidUri(firestoreData.songThumbnailUrl)) {
|
||||||
|
mergedProject.songThumbnailUrl = firestoreData.songThumbnailUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mergedProject
|
||||||
|
}
|
||||||
|
|
||||||
|
export const hydrateProjects = async (
|
||||||
|
projects = [],
|
||||||
|
{ ensureCover = false, ensureThumbnails = false } = {}
|
||||||
|
) => {
|
||||||
|
if (!Array.isArray(projects) || projects.length === 0) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.all(
|
||||||
|
projects.map(async (project) => {
|
||||||
|
const needsCover = ensureCover && !hasCoverAsset(project)
|
||||||
|
const needsThumbnail = ensureThumbnails && !hasThumbnailAsset(project)
|
||||||
|
|
||||||
|
if ((!needsCover && !needsThumbnail) || !project?.id) {
|
||||||
|
return project
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const snapshot = await projectsRef.doc(project.id).get()
|
||||||
|
if (!snapshot.exists) {
|
||||||
|
return project
|
||||||
|
}
|
||||||
|
const firestoreData = snapshot.data() || {}
|
||||||
|
return mergeProjectAssets(project, firestoreData, {
|
||||||
|
mergeCover: needsCover,
|
||||||
|
mergeThumbnails: needsThumbnail,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.log('hydrateProjects error', error?.message || error)
|
||||||
|
return project
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import useAlgoliaSearch from 'react-native-minuit/src/hooks/useAlgoliaSearch'
|
||||||
|
import { AlgoliaProjectConfig } from '../data/keys'
|
||||||
|
import { hasCoverAsset, hydrateProjects } from './searchProjectAssets'
|
||||||
|
|
||||||
|
const batchSize = 12
|
||||||
|
|
||||||
|
const usePlaylistMusicSearch = () => {
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [hydratedMusics, setHydratedMusics] = useState(null)
|
||||||
|
|
||||||
|
const normalizedSearch = useMemo(() => search.trim(), [search])
|
||||||
|
|
||||||
|
const { hits: musics, loading } = useAlgoliaSearch({
|
||||||
|
query: normalizedSearch,
|
||||||
|
algoliaObject: AlgoliaProjectConfig,
|
||||||
|
batch: batchSize,
|
||||||
|
condition: normalizedSearch.length > 0,
|
||||||
|
searchParams: {
|
||||||
|
filters: 'hasSong:true',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isCancelled = false
|
||||||
|
const nextMusics = Array.isArray(musics) ? musics : []
|
||||||
|
const requiresHydration =
|
||||||
|
nextMusics.length > 0 && nextMusics.some((project) => !hasCoverAsset(project))
|
||||||
|
|
||||||
|
if (!requiresHydration) {
|
||||||
|
setHydratedMusics(null)
|
||||||
|
return () => {
|
||||||
|
isCancelled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setHydratedMusics(null)
|
||||||
|
;(async () => {
|
||||||
|
const hydrated = await hydrateProjects(nextMusics, { ensureCover: true })
|
||||||
|
if (!isCancelled) {
|
||||||
|
setHydratedMusics(hydrated)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isCancelled = true
|
||||||
|
}
|
||||||
|
}, [musics])
|
||||||
|
|
||||||
|
return {
|
||||||
|
search,
|
||||||
|
setSearch,
|
||||||
|
musics: hydratedMusics ?? musics,
|
||||||
|
loading,
|
||||||
|
hasSearch: normalizedSearch.length > 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default usePlaylistMusicSearch
|
||||||
+1
-95
@@ -1,108 +1,14 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import useAlgoliaSearch from 'react-native-minuit/src/hooks/useAlgoliaSearch'
|
import useAlgoliaSearch from 'react-native-minuit/src/hooks/useAlgoliaSearch'
|
||||||
import { AlgoliaUserConfig, AlgoliaProjectConfig } from '../data/keys'
|
import { AlgoliaUserConfig, AlgoliaProjectConfig } from '../data/keys'
|
||||||
import { projectsRef } from '../config/firebase'
|
|
||||||
import { useUserData } from '../providers/UserDataProvider'
|
import { useUserData } from '../providers/UserDataProvider'
|
||||||
|
import { hasCoverAsset, hasThumbnailAsset, hydrateProjects } from './searchProjectAssets'
|
||||||
|
|
||||||
const batchSizes = {
|
const batchSizes = {
|
||||||
users: 5,
|
users: 5,
|
||||||
projects: 6,
|
projects: 6,
|
||||||
}
|
}
|
||||||
|
|
||||||
const isValidUri = (value) => typeof value === 'string' && value.trim().length > 0
|
|
||||||
|
|
||||||
const hasCoverAsset = (project) => {
|
|
||||||
if (!project) return false
|
|
||||||
if (isValidUri(project?.coverUrl)) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
const cover = project?.cover
|
|
||||||
if (!cover || typeof cover !== 'object') {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
const coverCandidates = [cover?.result, cover?.finalUrl, cover?.generatedBackground]
|
|
||||||
if (Array.isArray(cover?.options)) {
|
|
||||||
coverCandidates.push(
|
|
||||||
...cover.options.flatMap((option) => [option?.finalUrl, option?.generatedUrl])
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return coverCandidates.some(isValidUri)
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasThumbnailAsset = (project) =>
|
|
||||||
isValidUri(project?.thumbnailUrl) || isValidUri(project?.songThumbnailUrl)
|
|
||||||
|
|
||||||
const mergeProjectAssets = (
|
|
||||||
project,
|
|
||||||
firestoreData,
|
|
||||||
{ mergeCover = false, mergeThumbnails = false } = {}
|
|
||||||
) => {
|
|
||||||
if (!firestoreData || typeof firestoreData !== 'object') {
|
|
||||||
return project
|
|
||||||
}
|
|
||||||
const mergedProject = { ...project }
|
|
||||||
|
|
||||||
if (mergeCover) {
|
|
||||||
if (!isValidUri(mergedProject.coverUrl) && isValidUri(firestoreData.coverUrl)) {
|
|
||||||
mergedProject.coverUrl = firestoreData.coverUrl
|
|
||||||
}
|
|
||||||
const firestoreCover =
|
|
||||||
firestoreData.cover && typeof firestoreData.cover === 'object' ? firestoreData.cover : null
|
|
||||||
if (firestoreCover) {
|
|
||||||
mergedProject.cover = {
|
|
||||||
...(typeof mergedProject.cover === 'object' ? mergedProject.cover : {}),
|
|
||||||
...firestoreCover,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mergeThumbnails) {
|
|
||||||
if (!isValidUri(mergedProject.thumbnailUrl) && isValidUri(firestoreData.thumbnailUrl)) {
|
|
||||||
mergedProject.thumbnailUrl = firestoreData.thumbnailUrl
|
|
||||||
}
|
|
||||||
if (!isValidUri(mergedProject.songThumbnailUrl) && isValidUri(firestoreData.songThumbnailUrl)) {
|
|
||||||
mergedProject.songThumbnailUrl = firestoreData.songThumbnailUrl
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return mergedProject
|
|
||||||
}
|
|
||||||
|
|
||||||
const hydrateProjects = async (
|
|
||||||
projects = [],
|
|
||||||
{ ensureCover = false, ensureThumbnails = false } = {}
|
|
||||||
) => {
|
|
||||||
if (!Array.isArray(projects) || projects.length === 0) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
return Promise.all(
|
|
||||||
projects.map(async (project) => {
|
|
||||||
const needsCover = ensureCover && !hasCoverAsset(project)
|
|
||||||
const needsThumbnail = ensureThumbnails && !hasThumbnailAsset(project)
|
|
||||||
|
|
||||||
if ((!needsCover && !needsThumbnail) || !project?.id) {
|
|
||||||
return project
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const snapshot = await projectsRef.doc(project.id).get()
|
|
||||||
if (!snapshot.exists) {
|
|
||||||
return project
|
|
||||||
}
|
|
||||||
const firestoreData = snapshot.data() || {}
|
|
||||||
return mergeProjectAssets(project, firestoreData, {
|
|
||||||
mergeCover: needsCover,
|
|
||||||
mergeThumbnails: needsThumbnail,
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.log('useSearch.hydrateProjects error', error?.message || error)
|
|
||||||
return project
|
|
||||||
}
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const useSearch = () => {
|
const useSearch = () => {
|
||||||
const [selected, setSelected] = useState(null)
|
const [selected, setSelected] = useState(null)
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
|
|||||||
@@ -129,6 +129,17 @@ const AllMyPlaylist = ({ route }) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openAddTracks = useCallback(() => {
|
||||||
|
if (!selectedPlaylistId) return
|
||||||
|
|
||||||
|
SheetManager.show('PlaylistAddTracks', {
|
||||||
|
payload: {
|
||||||
|
playlistId: selectedPlaylistId,
|
||||||
|
existingMusicIds: Array.isArray(playlist?.musics) ? playlist.musics : [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}, [playlist?.musics, selectedPlaylistId])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page
|
<Page
|
||||||
headerType="NAVIGATION"
|
headerType="NAVIGATION"
|
||||||
@@ -141,9 +152,18 @@ const AllMyPlaylist = ({ route }) => {
|
|||||||
rightComponent={() => (
|
rightComponent={() => (
|
||||||
<>
|
<>
|
||||||
{selectedPlaylistId != null && (
|
{selectedPlaylistId != null && (
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
|
||||||
|
<Pressable onPress={openAddTracks}>
|
||||||
|
<BlurView intensity={20} tint="dark" style={styles.addTracksButton}>
|
||||||
|
<Text numberOfLines={1} style={styles.addTracksText}>
|
||||||
|
Ajouter des morceaux
|
||||||
|
</Text>
|
||||||
|
</BlurView>
|
||||||
|
</Pressable>
|
||||||
<Pressable onPress={confirmDelete}>
|
<Pressable onPress={confirmDelete}>
|
||||||
<Image source={icons.trash} style={{ width: 24, height: 24 }} />
|
<Image source={icons.trash} style={{ width: 24, height: 24 }} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
</View>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -288,3 +308,18 @@ const AllMyPlaylist = ({ route }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default AllMyPlaylist
|
export default AllMyPlaylist
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
addTracksButton: {
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderRadius: 999,
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: Palette.glass,
|
||||||
|
},
|
||||||
|
addTracksText: {
|
||||||
|
color: Palette.white,
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
import { BlurView } from 'expo-blur'
|
||||||
import { useRoute } from '@react-navigation/native'
|
import { useRoute } from '@react-navigation/native'
|
||||||
import React, { useCallback, useMemo, useState } from 'react'
|
import React, { useCallback, useMemo, useState } from 'react'
|
||||||
import { Image, Pressable, View } from 'react-native'
|
import { Image, Pressable, Text, View } from 'react-native'
|
||||||
import { SheetManager } from 'react-native-actions-sheet'
|
import { SheetManager } from 'react-native-actions-sheet'
|
||||||
import { useDataFromRef } from 'react-native-minuit/src/hooks'
|
import { useDataFromRef } from 'react-native-minuit/src/hooks'
|
||||||
import useDataFromArrayId from 'react-native-minuit/src/hooks/useDataFromArrayId'
|
import useDataFromArrayId from 'react-native-minuit/src/hooks/useDataFromArrayId'
|
||||||
@@ -12,7 +13,8 @@ import useNavigateToMusicDetails from '../../hooks/useNavigateToMusicDetails'
|
|||||||
import usePlayer from '../../hooks/usePlayer'
|
import usePlayer from '../../hooks/usePlayer'
|
||||||
import Page from '../../layouts/Page'
|
import Page from '../../layouts/Page'
|
||||||
import { goBack } from '../../navigation/NavigationService'
|
import { goBack } from '../../navigation/NavigationService'
|
||||||
import { gutters } from '../../styles'
|
import { gutters, Palette } from '../../styles'
|
||||||
|
import { FONT_FAMILY } from '../../styles/Fonts'
|
||||||
import { getProjectLikes, LIKE_TARGET } from '../../utils/likes'
|
import { getProjectLikes, LIKE_TARGET } from '../../utils/likes'
|
||||||
import MusicCard from './components/MusicCard'
|
import MusicCard from './components/MusicCard'
|
||||||
|
|
||||||
@@ -130,15 +132,35 @@ const PlaylistDetails = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openAddTracks = useCallback(() => {
|
||||||
|
if (!playlistId) return
|
||||||
|
|
||||||
|
SheetManager.show('PlaylistAddTracks', {
|
||||||
|
payload: {
|
||||||
|
playlistId,
|
||||||
|
existingMusicIds: Array.isArray(playlist?.musics) ? playlist.musics : [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}, [playlist?.musics, playlistId])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page
|
<Page
|
||||||
headerType="NAVIGATION"
|
headerType="NAVIGATION"
|
||||||
backgroundImg={background.libraryBG}
|
backgroundImg={background.libraryBG}
|
||||||
title={playlist?.name || 'Playlist'}
|
title={playlist?.name || 'Playlist'}
|
||||||
rightComponent={() => (
|
rightComponent={() => (
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
|
||||||
|
<Pressable onPress={openAddTracks}>
|
||||||
|
<BlurView intensity={20} tint="dark" style={styles.addTracksButton}>
|
||||||
|
<Text numberOfLines={1} style={styles.addTracksText}>
|
||||||
|
Ajouter des morceaux
|
||||||
|
</Text>
|
||||||
|
</BlurView>
|
||||||
|
</Pressable>
|
||||||
<Pressable onPress={confirmDelete}>
|
<Pressable onPress={confirmDelete}>
|
||||||
<Image source={icons.trash} style={{ width: 24, height: 24 }} />
|
<Image source={icons.trash} style={{ width: 24, height: 24 }} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
</View>
|
||||||
)}
|
)}
|
||||||
contentContainerStyle={{ paddingBottom: gutters * 2 }}
|
contentContainerStyle={{ paddingBottom: gutters * 2 }}
|
||||||
>
|
>
|
||||||
@@ -177,3 +199,18 @@ const PlaylistDetails = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default PlaylistDetails
|
export default PlaylistDetails
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
addTracksButton: {
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderRadius: 999,
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: Palette.glass,
|
||||||
|
},
|
||||||
|
addTracksText: {
|
||||||
|
color: Palette.white,
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { playlistsRef } from '../../../config/firebase'
|
import { arrayUnion, playlistsRef } from '../../../config/firebase'
|
||||||
|
|
||||||
export const createPlaylist = async (payload = {}) => {
|
export const createPlaylist = async (payload = {}) => {
|
||||||
try {
|
try {
|
||||||
@@ -21,3 +21,19 @@ export const createPlaylist = async (payload = {}) => {
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const addMusicToPlaylist = async ({ playlistId, projectId }) => {
|
||||||
|
try {
|
||||||
|
if (typeof playlistId !== 'string' || typeof projectId !== 'string') {
|
||||||
|
throw new Error('Identifiants de playlist ou de morceau invalides')
|
||||||
|
}
|
||||||
|
|
||||||
|
await playlistsRef.doc(playlistId).update({
|
||||||
|
musics: arrayUnion(projectId),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error adding music to playlist:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ import Palette from '../styles/Palette.js'
|
|||||||
|
|
||||||
export default ({ navigation }) => {
|
export default ({ navigation }) => {
|
||||||
const [, setTooltip] = useGlobal('_tooltip')
|
const [, setTooltip] = useGlobal('_tooltip')
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState(__DEV__ ? 'az@az.az' : '')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState(__DEV__ ? 'Minuit33' : '')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const afterLoginNavigate = useCallback(async () => {
|
const afterLoginNavigate = useCallback(async () => {
|
||||||
const uid = firebase.auth().currentUser?.uid
|
const uid = firebase.auth().currentUser?.uid
|
||||||
|
|||||||
@@ -18,13 +18,13 @@ import { Routes } from '../../navigation'
|
|||||||
import { goBack, navigate } from '../../navigation/NavigationService'
|
import { goBack, navigate } from '../../navigation/NavigationService'
|
||||||
import { gutters, Palette } from '../../styles'
|
import { gutters, Palette } from '../../styles'
|
||||||
import { FONT_FAMILY } from '../../styles/Fonts'
|
import { FONT_FAMILY } from '../../styles/Fonts'
|
||||||
|
import { clampSyncOffsetMs } from '../../utils/playbackSync'
|
||||||
import trimLeadingDuplicateSection from '../../utils/trimLeadingDuplicateSection'
|
import trimLeadingDuplicateSection from '../../utils/trimLeadingDuplicateSection'
|
||||||
import CreateLyricsHeader from '../Writing/components/CreateLyricsHeader'
|
import CreateLyricsHeader from '../Writing/components/CreateLyricsHeader'
|
||||||
|
|
||||||
const TIME_BEFORE_INCREMENT_MS = 20000 // 20s
|
const TIME_BEFORE_INCREMENT_MS = 20000 // 20s
|
||||||
const COUNTDOWN_SECONDS = 10
|
const COUNTDOWN_SECONDS = 10
|
||||||
const CAMERA_STARTUP_DELAY_MS = 250
|
const CAMERA_STARTUP_DELAY_MS = 250
|
||||||
const MAX_SYNC_OFFSET_MS = 2000
|
|
||||||
const KARAOKE_LEAD_S = 0.18
|
const KARAOKE_LEAD_S = 0.18
|
||||||
const LOG_PREFIX = '[RecordPlayback]'
|
const LOG_PREFIX = '[RecordPlayback]'
|
||||||
const KEEP_AWAKE_TAG = 'record-playback'
|
const KEEP_AWAKE_TAG = 'record-playback'
|
||||||
@@ -758,7 +758,7 @@ const RecordPlayback = ({ route }) => {
|
|||||||
playbackStartedAtRef.current && recordingStartTimeRef.current
|
playbackStartedAtRef.current && recordingStartTimeRef.current
|
||||||
? playbackStartedAtRef.current - recordingStartTimeRef.current
|
? playbackStartedAtRef.current - recordingStartTimeRef.current
|
||||||
: 0
|
: 0
|
||||||
const syncOffsetMs = Math.max(0, Math.min(MAX_SYNC_OFFSET_MS, Math.round(rawOffsetMs || 0)))
|
const syncOffsetMs = clampSyncOffsetMs(Math.round(rawOffsetMs || 0))
|
||||||
log('Computed sync offset', { rawOffsetMs, syncOffsetMs })
|
log('Computed sync offset', { rawOffsetMs, syncOffsetMs })
|
||||||
|
|
||||||
if (video?.uri) {
|
if (video?.uri) {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import CreateLyricsHeader from '../Writing/components/CreateLyricsHeader'
|
|||||||
import { background } from '../../assets'
|
import { background } from '../../assets'
|
||||||
import RestartSpinnerIcon from '../../assets/UI/RestartSpinnerIcon'
|
import RestartSpinnerIcon from '../../assets/UI/RestartSpinnerIcon'
|
||||||
import { registerBlobUrl, releaseBlobUrl } from '../../utils/blobUrlCache'
|
import { registerBlobUrl, releaseBlobUrl } from '../../utils/blobUrlCache'
|
||||||
|
import { clampSyncOffsetMs } from '../../utils/playbackSync'
|
||||||
import trimLeadingDuplicateSection from '../../utils/trimLeadingDuplicateSection'
|
import trimLeadingDuplicateSection from '../../utils/trimLeadingDuplicateSection'
|
||||||
|
|
||||||
const TIME_BEFORE_INCREMENT_MS = 20000
|
const TIME_BEFORE_INCREMENT_MS = 20000
|
||||||
@@ -29,7 +30,6 @@ const MEDIA_BOOTSTRAP_DELAY_MS = 250
|
|||||||
const MEDIA_RETRY_DELAY_MS = 700
|
const MEDIA_RETRY_DELAY_MS = 700
|
||||||
const MEDIA_MAX_RETRIES = 2
|
const MEDIA_MAX_RETRIES = 2
|
||||||
const CAMERA_STARTUP_DELAY_MS = 250
|
const CAMERA_STARTUP_DELAY_MS = 250
|
||||||
const MAX_SYNC_OFFSET_MS = 2000
|
|
||||||
const KARAOKE_LEAD_S = 0.18
|
const KARAOKE_LEAD_S = 0.18
|
||||||
|
|
||||||
const toSeconds = (v) => {
|
const toSeconds = (v) => {
|
||||||
@@ -547,7 +547,7 @@ const RecordPlayback = ({ route }) => {
|
|||||||
playbackStartedAtRef.current != null && recordingStartAtRef.current != null
|
playbackStartedAtRef.current != null && recordingStartAtRef.current != null
|
||||||
? playbackStartedAtRef.current - recordingStartAtRef.current
|
? playbackStartedAtRef.current - recordingStartAtRef.current
|
||||||
: 0
|
: 0
|
||||||
const syncOffsetMs = Math.max(0, Math.min(MAX_SYNC_OFFSET_MS, Math.round(rawOffsetMs || 0)))
|
const syncOffsetMs = clampSyncOffsetMs(Math.round(rawOffsetMs || 0))
|
||||||
|
|
||||||
navigate(Routes.RecordedPlayback, {
|
navigate(Routes.RecordedPlayback, {
|
||||||
project,
|
project,
|
||||||
|
|||||||
@@ -4,18 +4,28 @@ import * as FileSystem from 'expo-file-system'
|
|||||||
import { VideoView, useVideoPlayer } from 'expo-video'
|
import { VideoView, useVideoPlayer } from 'expo-video'
|
||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { Image, Pressable, View } from 'react-native'
|
import { Image, Pressable, View } from 'react-native'
|
||||||
import { background, icons } from '../../assets'
|
import { icons } from '../../assets'
|
||||||
import BorderGradientButton from '../../components/BorderGradientButton'
|
import BorderGradientButton from '../../components/BorderGradientButton'
|
||||||
import GradientButton from '../../components/GradientButton'
|
import GradientButton from '../../components/GradientButton'
|
||||||
import MusicLandHeader from '../../components/MusicLandHeader'
|
import MusicLandHeader from '../../components/MusicLandHeader'
|
||||||
import ProgressSlider from '../../components/player/ProgressSlider'
|
import ProgressSlider from '../../components/player/ProgressSlider'
|
||||||
|
import SyncOffsetSlider from '../../components/player/SyncOffsetSlider'
|
||||||
import Page from '../../layouts/Page'
|
import Page from '../../layouts/Page'
|
||||||
import { Routes } from '../../navigation'
|
import { Routes } from '../../navigation'
|
||||||
import { goBack, navigate } from '../../navigation/NavigationService'
|
import { goBack, navigate } from '../../navigation/NavigationService'
|
||||||
import { gutters, Palette } from '../../styles'
|
import { gutters, Palette } from '../../styles'
|
||||||
import { FONT_FAMILY } from '../../styles/Fonts'
|
import {
|
||||||
|
clampSyncOffsetMs,
|
||||||
|
getAudioPositionMs,
|
||||||
|
getTimelineDurationMs,
|
||||||
|
getTimelinePositionMs,
|
||||||
|
getVideoPositionMs,
|
||||||
|
snapSyncOffsetMs,
|
||||||
|
} from '../../utils/playbackSync'
|
||||||
|
|
||||||
const RecordedPlayback = ({ route }) => {
|
const RecordedPlayback = ({ route }) => {
|
||||||
const { videoUri, project, syncOffsetMs = 0 } = route.params || {}
|
const { videoUri, project, syncOffsetMs = 0 } = route.params || {}
|
||||||
|
const initialSyncOffsetMs = useMemo(() => snapSyncOffsetMs(syncOffsetMs), [syncOffsetMs])
|
||||||
|
|
||||||
const songUrl = project?.songUrl || null
|
const songUrl = project?.songUrl || null
|
||||||
const audioPlayer = useSharedAudioPlayer(songUrl ? { uri: songUrl } : undefined, {
|
const audioPlayer = useSharedAudioPlayer(songUrl ? { uri: songUrl } : undefined, {
|
||||||
@@ -26,10 +36,10 @@ const RecordedPlayback = ({ route }) => {
|
|||||||
coverUrl: project?.coverUrl || null,
|
coverUrl: project?.coverUrl || null,
|
||||||
metadata: { projectId: project?.id, screen: 'RecordedPlayback' },
|
metadata: { projectId: project?.id, screen: 'RecordedPlayback' },
|
||||||
})
|
})
|
||||||
const videoPlayer = useVideoPlayer(videoUri || null, (p) => {
|
const videoPlayer = useVideoPlayer(videoUri || null, (player) => {
|
||||||
p.loop = false
|
player.loop = false
|
||||||
p.muted = true // recorded video has no audio; keep muted anyway
|
player.muted = true
|
||||||
p.timeUpdateEventInterval = 0.2
|
player.timeUpdateEventInterval = 0.2
|
||||||
})
|
})
|
||||||
|
|
||||||
const [progressInfo, setProgressInfo] = useState({
|
const [progressInfo, setProgressInfo] = useState({
|
||||||
@@ -37,18 +47,46 @@ const RecordedPlayback = ({ route }) => {
|
|||||||
dur: 0,
|
dur: 0,
|
||||||
isPlaying: false,
|
isPlaying: false,
|
||||||
})
|
})
|
||||||
|
const [currentSyncOffsetMs, setCurrentSyncOffsetMs] = useState(initialSyncOffsetMs)
|
||||||
|
|
||||||
const playbackEndedRef = useRef(false)
|
const playbackEndedRef = useRef(false)
|
||||||
|
const syncOffsetRef = useRef(initialSyncOffsetMs)
|
||||||
|
|
||||||
const syncOffsetRef = useRef(Math.max(0, Number(syncOffsetMs) || 0))
|
const getCurrentRawAudioPositionMs = useCallback(
|
||||||
|
() => Math.max(0, (audioPlayer?.currentTime || 0) * 1000),
|
||||||
|
[audioPlayer]
|
||||||
|
)
|
||||||
|
|
||||||
|
const getCurrentRawAudioDurationMs = useCallback(
|
||||||
|
() => Math.max(0, (audioPlayer?.duration || 0) * 1000),
|
||||||
|
[audioPlayer]
|
||||||
|
)
|
||||||
|
|
||||||
|
const alignPlaybackToTimeline = useCallback(
|
||||||
|
async (timelineMs, offsetMs = syncOffsetRef.current) => {
|
||||||
|
const safeOffsetMs = clampSyncOffsetMs(offsetMs)
|
||||||
|
const safeTimelineMs = Math.max(0, Number(timelineMs) || 0)
|
||||||
|
const audioTargetMs = getAudioPositionMs(safeTimelineMs, safeOffsetMs)
|
||||||
|
const videoTargetMs = getVideoPositionMs(safeTimelineMs, safeOffsetMs)
|
||||||
|
|
||||||
|
if (audioPlayer) {
|
||||||
|
await audioPlayer.seekTo?.(audioTargetMs / 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (videoPlayer) {
|
||||||
|
videoPlayer.currentTime = videoTargetMs / 1000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[audioPlayer, videoPlayer]
|
||||||
|
)
|
||||||
|
|
||||||
// Start both players on mount
|
|
||||||
const stopPlayback = useCallback(async () => {
|
const stopPlayback = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
if (audioPlayer) await audioPlayer.pause?.()
|
if (audioPlayer) await audioPlayer.pause?.()
|
||||||
} catch (e) { }
|
} catch (e) {}
|
||||||
try {
|
try {
|
||||||
videoPlayer?.pause?.()
|
videoPlayer?.pause?.()
|
||||||
} catch (e) { }
|
} catch (e) {}
|
||||||
}, [audioPlayer, videoPlayer])
|
}, [audioPlayer, videoPlayer])
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
@@ -61,57 +99,67 @@ const RecordedPlayback = ({ route }) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
playbackEndedRef.current = false
|
playbackEndedRef.current = false
|
||||||
|
syncOffsetRef.current = initialSyncOffsetMs
|
||||||
|
setCurrentSyncOffsetMs(initialSyncOffsetMs)
|
||||||
|
|
||||||
const start = async () => {
|
const start = async () => {
|
||||||
try {
|
try {
|
||||||
if (videoPlayer && syncOffsetRef.current > 0) {
|
await alignPlaybackToTimeline(0, initialSyncOffsetMs)
|
||||||
videoPlayer.currentTime = syncOffsetRef.current / 1000
|
|
||||||
}
|
|
||||||
if (audioPlayer && songUrl) await audioPlayer.play?.()
|
if (audioPlayer && songUrl) await audioPlayer.play?.()
|
||||||
if (videoPlayer) videoPlayer.play()
|
if (videoPlayer) videoPlayer.play()
|
||||||
} catch (e) { }
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
start()
|
|
||||||
|
void start()
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
playbackEndedRef.current = false
|
playbackEndedRef.current = false
|
||||||
void stopPlayback()
|
void stopPlayback()
|
||||||
}
|
}
|
||||||
}, [audioPlayer, songUrl, stopPlayback, videoPlayer])
|
}, [alignPlaybackToTimeline, audioPlayer, initialSyncOffsetMs, songUrl, stopPlayback, videoPlayer])
|
||||||
|
|
||||||
// Poll from audio player for progress display; keep video in sync if drifting
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = global.setInterval(() => {
|
const id = global.setInterval(() => {
|
||||||
try {
|
try {
|
||||||
const dur = (audioPlayer?.duration || 0) * 1000
|
const rawDurationMs = getCurrentRawAudioDurationMs()
|
||||||
const pos = (audioPlayer?.currentTime || 0) * 1000
|
const rawAudioMs = getCurrentRawAudioPositionMs()
|
||||||
const playing = !!audioPlayer?.playing || !!videoPlayer?.playing
|
const nextDurationMs = getTimelineDurationMs(rawDurationMs, syncOffsetRef.current)
|
||||||
setProgressInfo({ pos, dur, isPlaying: playing })
|
const nextPositionMs = getTimelinePositionMs(rawAudioMs, syncOffsetRef.current)
|
||||||
|
const safePositionMs =
|
||||||
|
nextDurationMs > 0 ? Math.min(nextPositionMs, nextDurationMs) : nextPositionMs
|
||||||
|
const isPlaying = !!audioPlayer?.playing || !!videoPlayer?.playing
|
||||||
|
|
||||||
|
setProgressInfo({
|
||||||
|
pos: safePositionMs,
|
||||||
|
dur: nextDurationMs,
|
||||||
|
isPlaying,
|
||||||
|
})
|
||||||
|
|
||||||
// basic drift correction: if desync > 300ms, align video
|
|
||||||
if (videoPlayer && !Number.isNaN(videoPlayer.currentTime)) {
|
if (videoPlayer && !Number.isNaN(videoPlayer.currentTime)) {
|
||||||
const offset = syncOffsetRef.current || 0
|
const expectedVideoMs = getVideoPositionMs(safePositionMs, syncOffsetRef.current)
|
||||||
const expected = Math.max(0, (pos || 0) + offset)
|
const currentVideoMs = (videoPlayer.currentTime || 0) * 1000
|
||||||
const v = (videoPlayer.currentTime || 0) * 1000
|
const driftMs = Math.abs(currentVideoMs - expectedVideoMs)
|
||||||
const drift = Math.abs(v - expected)
|
|
||||||
if (drift > 350) {
|
|
||||||
videoPlayer.currentTime = Math.max(0, expected / 1000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) { }
|
|
||||||
}, 250)
|
|
||||||
return () => global.clearInterval(id)
|
|
||||||
}, [audioPlayer, videoPlayer])
|
|
||||||
|
|
||||||
const onSeek = async (targetMs) => {
|
if (driftMs > 350) {
|
||||||
|
videoPlayer.currentTime = Math.max(0, expectedVideoMs / 1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
}, 250)
|
||||||
|
|
||||||
|
return () => global.clearInterval(id)
|
||||||
|
}, [audioPlayer, getCurrentRawAudioDurationMs, getCurrentRawAudioPositionMs, videoPlayer])
|
||||||
|
|
||||||
|
const onSeek = useCallback(
|
||||||
|
async (targetMs) => {
|
||||||
try {
|
try {
|
||||||
const dur = progressInfo.dur || 0
|
const durationMs = progressInfo.dur || 0
|
||||||
const pos = Math.max(0, Math.min(dur, Math.floor(targetMs)))
|
const safeTargetMs = Math.max(0, Math.min(durationMs, Math.floor(targetMs)))
|
||||||
if (audioPlayer && dur > 0) await audioPlayer.seekTo?.(Math.floor(pos / 1000))
|
await alignPlaybackToTimeline(safeTargetMs)
|
||||||
if (videoPlayer) {
|
} catch (e) {}
|
||||||
const offset = syncOffsetRef.current || 0
|
},
|
||||||
videoPlayer.currentTime = Math.max(0, (pos + offset) / 1000)
|
[alignPlaybackToTimeline, progressInfo.dur]
|
||||||
}
|
)
|
||||||
} catch (e) { }
|
|
||||||
}
|
|
||||||
|
|
||||||
const onSeekStart = useCallback(() => {
|
const onSeekStart = useCallback(() => {
|
||||||
playbackEndedRef.current = false
|
playbackEndedRef.current = false
|
||||||
@@ -120,10 +168,10 @@ const RecordedPlayback = ({ route }) => {
|
|||||||
const pauseDuringSeek = useCallback(async () => {
|
const pauseDuringSeek = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
if (audioPlayer?.playing) await audioPlayer.pause?.()
|
if (audioPlayer?.playing) await audioPlayer.pause?.()
|
||||||
} catch (e) { }
|
} catch (e) {}
|
||||||
try {
|
try {
|
||||||
if (videoPlayer?.playing) videoPlayer.pause()
|
if (videoPlayer?.playing) videoPlayer.pause()
|
||||||
} catch (e) { }
|
} catch (e) {}
|
||||||
}, [audioPlayer, videoPlayer])
|
}, [audioPlayer, videoPlayer])
|
||||||
|
|
||||||
const resumeAfterSeek = useCallback(async () => {
|
const resumeAfterSeek = useCallback(async () => {
|
||||||
@@ -133,36 +181,72 @@ const RecordedPlayback = ({ route }) => {
|
|||||||
} else if (audioPlayer) {
|
} else if (audioPlayer) {
|
||||||
await audioPlayer.play?.()
|
await audioPlayer.play?.()
|
||||||
}
|
}
|
||||||
} catch (e) { }
|
} catch (e) {}
|
||||||
try {
|
try {
|
||||||
if (videoPlayer) videoPlayer.play()
|
if (videoPlayer) videoPlayer.play()
|
||||||
} catch (e) { }
|
} catch (e) {}
|
||||||
}, [audioPlayer, videoPlayer])
|
}, [audioPlayer, videoPlayer])
|
||||||
|
|
||||||
const sliderProgress = useMemo(() => {
|
const handleSyncOffsetChange = useCallback(
|
||||||
return progressInfo.dur ? Math.min(1, (progressInfo.pos || 0) / progressInfo.dur) : 0
|
(nextOffsetMs) => {
|
||||||
}, [progressInfo])
|
const safeOffsetMs = snapSyncOffsetMs(nextOffsetMs)
|
||||||
|
setCurrentSyncOffsetMs((prevOffsetMs) =>
|
||||||
|
prevOffsetMs === safeOffsetMs ? prevOffsetMs : safeOffsetMs
|
||||||
|
)
|
||||||
|
|
||||||
|
const previousOffsetMs = syncOffsetRef.current
|
||||||
|
if (safeOffsetMs === previousOffsetMs) return
|
||||||
|
|
||||||
|
playbackEndedRef.current = false
|
||||||
|
|
||||||
|
const rawAudioMs = getCurrentRawAudioPositionMs()
|
||||||
|
const rawDurationMs = getCurrentRawAudioDurationMs()
|
||||||
|
const currentTimelineMs = getTimelinePositionMs(rawAudioMs, previousOffsetMs)
|
||||||
|
const nextDurationMs = getTimelineDurationMs(rawDurationMs, safeOffsetMs)
|
||||||
|
const nextTimelineMs =
|
||||||
|
nextDurationMs > 0 ? Math.min(currentTimelineMs, nextDurationMs) : currentTimelineMs
|
||||||
|
|
||||||
|
syncOffsetRef.current = safeOffsetMs
|
||||||
|
|
||||||
|
setProgressInfo({
|
||||||
|
pos: nextTimelineMs,
|
||||||
|
dur: nextDurationMs,
|
||||||
|
isPlaying: !!audioPlayer?.playing || !!videoPlayer?.playing,
|
||||||
|
})
|
||||||
|
|
||||||
|
void alignPlaybackToTimeline(nextTimelineMs, safeOffsetMs)
|
||||||
|
},
|
||||||
|
[
|
||||||
|
alignPlaybackToTimeline,
|
||||||
|
audioPlayer?.playing,
|
||||||
|
getCurrentRawAudioDurationMs,
|
||||||
|
getCurrentRawAudioPositionMs,
|
||||||
|
videoPlayer?.playing,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const duration = progressInfo?.dur || 0
|
const durationMs = progressInfo?.dur || 0
|
||||||
if (!duration) return
|
if (!durationMs) return
|
||||||
const position = progressInfo?.pos || 0
|
|
||||||
if (playbackEndedRef.current && duration - position > 1000) {
|
const positionMs = progressInfo?.pos || 0
|
||||||
|
if (playbackEndedRef.current && durationMs - positionMs > 1000) {
|
||||||
playbackEndedRef.current = false
|
playbackEndedRef.current = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const remaining = Math.max(0, duration - position)
|
|
||||||
if (remaining <= 400 && !playbackEndedRef.current) {
|
const remainingMs = Math.max(0, durationMs - positionMs)
|
||||||
|
if (remainingMs <= 400 && !playbackEndedRef.current) {
|
||||||
playbackEndedRef.current = true
|
playbackEndedRef.current = true
|
||||||
void stopPlayback()
|
void stopPlayback()
|
||||||
}
|
}
|
||||||
}, [progressInfo, stopPlayback])
|
}, [progressInfo, stopPlayback])
|
||||||
|
|
||||||
const handleTogglePlayback = async () => {
|
const handleTogglePlayback = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const duration = progressInfo?.dur || 0
|
const durationMs = progressInfo?.dur || 0
|
||||||
const position = progressInfo?.pos || 0
|
const positionMs = progressInfo?.pos || 0
|
||||||
const isAtEnd = duration > 0 && duration - position < 1000
|
const isAtEnd = durationMs > 0 && durationMs - positionMs < 1000
|
||||||
const isCurrentlyPlaying = !!audioPlayer?.playing || !!videoPlayer?.playing
|
const isCurrentlyPlaying = !!audioPlayer?.playing || !!videoPlayer?.playing
|
||||||
|
|
||||||
if (isCurrentlyPlaying) {
|
if (isCurrentlyPlaying) {
|
||||||
@@ -173,18 +257,22 @@ const RecordedPlayback = ({ route }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isAtEnd) {
|
if (isAtEnd) {
|
||||||
if (audioPlayer) await audioPlayer.seekTo?.(0)
|
await alignPlaybackToTimeline(0)
|
||||||
if (videoPlayer) {
|
|
||||||
const offset = syncOffsetRef.current || 0
|
|
||||||
videoPlayer.currentTime = Math.max(0, offset / 1000)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
playbackEndedRef.current = false
|
playbackEndedRef.current = false
|
||||||
if (songUrl && audioPlayer) await audioPlayer.play?.()
|
|
||||||
if (videoPlayer) videoPlayer.play()
|
if (songUrl && audioPlayer) {
|
||||||
} catch (e) { }
|
if (audioPlayer?.resume) {
|
||||||
|
await audioPlayer.resume?.()
|
||||||
|
} else {
|
||||||
|
await audioPlayer.play?.()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (videoPlayer) videoPlayer.play()
|
||||||
|
} catch (e) {}
|
||||||
|
}, [alignPlaybackToTimeline, audioPlayer, progressInfo, songUrl, videoPlayer])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page backgroundColor={Palette.grayMid} headerType="NONE">
|
<Page backgroundColor={Palette.grayMid} headerType="NONE">
|
||||||
@@ -206,6 +294,8 @@ const RecordedPlayback = ({ route }) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<View style={{ width: '80%', alignSelf: 'center', gap: 18 }}>
|
||||||
<ProgressSlider
|
<ProgressSlider
|
||||||
positionMs={progressInfo.pos}
|
positionMs={progressInfo.pos}
|
||||||
durationMs={progressInfo.dur}
|
durationMs={progressInfo.dur}
|
||||||
@@ -216,6 +306,13 @@ const RecordedPlayback = ({ route }) => {
|
|||||||
onPlay={resumeAfterSeek}
|
onPlay={resumeAfterSeek}
|
||||||
disabled={!songUrl}
|
disabled={!songUrl}
|
||||||
/>
|
/>
|
||||||
|
<SyncOffsetSlider
|
||||||
|
valueMs={currentSyncOffsetMs}
|
||||||
|
onChange={handleSyncOffsetChange}
|
||||||
|
disabled={!songUrl}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={handleTogglePlayback}
|
onPress={handleTogglePlayback}
|
||||||
style={{
|
style={{
|
||||||
@@ -237,17 +334,19 @@ const RecordedPlayback = ({ route }) => {
|
|||||||
/>
|
/>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={{ width: '80%', alignSelf: 'center', marginTop: 4, gap: 12 }}>
|
<View style={{ width: '80%', alignSelf: 'center', marginTop: 4, gap: 12 }}>
|
||||||
<GradientButton
|
<GradientButton
|
||||||
title="Je valide"
|
title="Je valide"
|
||||||
onPress={async () => {
|
onPress={async () => {
|
||||||
try {
|
try {
|
||||||
await stopPlayback()
|
await stopPlayback()
|
||||||
} catch (e) { }
|
} catch (e) {}
|
||||||
navigate(Routes.PlaybackDownload, {
|
navigate(Routes.PlaybackDownload, {
|
||||||
action: 'playback',
|
action: 'playback',
|
||||||
uri: videoUri,
|
uri: videoUri,
|
||||||
project,
|
project,
|
||||||
|
syncOffsetMs: syncOffsetRef.current,
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -256,16 +355,17 @@ const RecordedPlayback = ({ route }) => {
|
|||||||
onPress={async () => {
|
onPress={async () => {
|
||||||
try {
|
try {
|
||||||
await stopPlayback()
|
await stopPlayback()
|
||||||
} catch (e) { }
|
} catch (e) {}
|
||||||
try {
|
try {
|
||||||
if (videoUri) {
|
if (videoUri) {
|
||||||
const info = await FileSystem.getInfoAsync(videoUri)
|
const info = await FileSystem.getInfoAsync(videoUri)
|
||||||
if (info?.exists)
|
if (info?.exists) {
|
||||||
await FileSystem.deleteAsync(videoUri, {
|
await FileSystem.deleteAsync(videoUri, {
|
||||||
idempotent: true,
|
idempotent: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} catch (e) { }
|
}
|
||||||
|
} catch (e) {}
|
||||||
navigate(Routes.RecordPlayback, { project })
|
navigate(Routes.RecordPlayback, { project })
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useFocusEffect } from '@react-navigation/native'
|
import { useFocusEffect } from '@react-navigation/native'
|
||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { Image, Pressable, Text, View } from 'react-native'
|
import { Image, Pressable, Text, View } from 'react-native'
|
||||||
import { background, icons } from '../../assets'
|
import { icons } from '../../assets'
|
||||||
import BorderGradientButton from '../../components/BorderGradientButton'
|
import BorderGradientButton from '../../components/BorderGradientButton'
|
||||||
import GradientButton from '../../components/GradientButton'
|
import GradientButton from '../../components/GradientButton'
|
||||||
import MusicLandHeader from '../../components/MusicLandHeader'
|
import MusicLandHeader from '../../components/MusicLandHeader'
|
||||||
import Slider from '../../components/Slider'
|
import ProgressSlider from '../../components/player/ProgressSlider'
|
||||||
|
import SyncOffsetSlider from '../../components/player/SyncOffsetSlider'
|
||||||
import Page from '../../layouts/Page'
|
import Page from '../../layouts/Page'
|
||||||
import { Routes } from '../../navigation'
|
import { Routes } from '../../navigation'
|
||||||
import { goBack, navigate } from '../../navigation/NavigationService'
|
import { goBack, navigate } from '../../navigation/NavigationService'
|
||||||
@@ -13,26 +14,22 @@ import { gutters, Palette } from '../../styles'
|
|||||||
import { isWeb } from '../../hooks/useLayoutType'
|
import { isWeb } from '../../hooks/useLayoutType'
|
||||||
import useSharedAudioPlayer from '../../hooks/useSharedAudioPlayer'
|
import useSharedAudioPlayer from '../../hooks/useSharedAudioPlayer'
|
||||||
import { releaseBlobUrl } from '../../utils/blobUrlCache'
|
import { releaseBlobUrl } from '../../utils/blobUrlCache'
|
||||||
|
import {
|
||||||
const fmtSeconds = (s) => {
|
clampSyncOffsetMs,
|
||||||
const total = Math.max(0, Math.floor(Number(s || 0)))
|
getAudioPositionMs,
|
||||||
const m = Math.floor(total / 60).toString()
|
getTimelineDurationMs,
|
||||||
const sec = (total % 60).toString().padStart(2, '0')
|
getTimelinePositionMs,
|
||||||
return `${m}:${sec}`
|
getVideoPositionMs,
|
||||||
}
|
snapSyncOffsetMs,
|
||||||
|
} from '../../utils/playbackSync'
|
||||||
const toSeconds = (value) => {
|
|
||||||
const n = Number(value ?? 0)
|
|
||||||
if (!Number.isFinite(n) || n < 0) return 0
|
|
||||||
return n > 10000 ? n / 1000 : n // heuristique ms → s
|
|
||||||
}
|
|
||||||
|
|
||||||
const WEB_PREVIEW_WIDTH = 360
|
const WEB_PREVIEW_WIDTH = 360
|
||||||
|
|
||||||
const RecordedPlayback = ({ route }) => {
|
const RecordedPlayback = ({ route }) => {
|
||||||
const { videoUri, project, syncOffsetMs = 0 } = route.params || {}
|
const { videoUri, project, syncOffsetMs = 0 } = route.params || {}
|
||||||
|
const initialSyncOffsetMs = useMemo(() => snapSyncOffsetMs(syncOffsetMs), [syncOffsetMs])
|
||||||
const songUrl = project?.songUrl || null
|
const songUrl = project?.songUrl || null
|
||||||
// AUDIO PLAYER (expo-audio → seconds)
|
|
||||||
const audioPlayer = useSharedAudioPlayer(songUrl ? { uri: songUrl } : undefined, {
|
const audioPlayer = useSharedAudioPlayer(songUrl ? { uri: songUrl } : undefined, {
|
||||||
id: project?.id ? `recorded-${project.id}` : songUrl ? `recorded-${songUrl}` : undefined,
|
id: project?.id ? `recorded-${project.id}` : songUrl ? `recorded-${songUrl}` : undefined,
|
||||||
title: typeof project?.title === 'string' ? project.title : 'Sans titre',
|
title: typeof project?.title === 'string' ? project.title : 'Sans titre',
|
||||||
@@ -42,27 +39,55 @@ const RecordedPlayback = ({ route }) => {
|
|||||||
metadata: { projectId: project?.id, screen: 'RecordedPlayback' },
|
metadata: { projectId: project?.id, screen: 'RecordedPlayback' },
|
||||||
})
|
})
|
||||||
|
|
||||||
// Optionnel: si tu veux quand même un rendu vidéo sur web si tu as une source
|
|
||||||
const videoElRef = useRef(null)
|
const videoElRef = useRef(null)
|
||||||
const syncOffsetRef = useRef(Math.max(0, Number(syncOffsetMs) || 0) / 1000)
|
|
||||||
const playbackEndedRef = useRef(false)
|
const playbackEndedRef = useRef(false)
|
||||||
const shouldPreserveBlobRef = useRef(false)
|
const shouldPreserveBlobRef = useRef(false)
|
||||||
|
const syncOffsetRef = useRef(initialSyncOffsetMs)
|
||||||
|
|
||||||
const [progress, setProgress] = useState({
|
const [progressInfo, setProgressInfo] = useState({
|
||||||
posS: 0, // secondes
|
pos: 0,
|
||||||
durS: 0, // secondes
|
dur: 0,
|
||||||
playing: false,
|
isPlaying: false,
|
||||||
})
|
})
|
||||||
|
const [currentSyncOffsetMs, setCurrentSyncOffsetMs] = useState(initialSyncOffsetMs)
|
||||||
|
|
||||||
|
const getCurrentRawAudioPositionMs = useCallback(
|
||||||
|
() => Math.max(0, (audioPlayer?.currentTime || 0) * 1000),
|
||||||
|
[audioPlayer]
|
||||||
|
)
|
||||||
|
|
||||||
|
const getCurrentRawAudioDurationMs = useCallback(
|
||||||
|
() => Math.max(0, (audioPlayer?.duration || 0) * 1000),
|
||||||
|
[audioPlayer]
|
||||||
|
)
|
||||||
|
|
||||||
|
const alignPlaybackToTimeline = useCallback(
|
||||||
|
async (timelineMs, offsetMs = syncOffsetRef.current) => {
|
||||||
|
const safeOffsetMs = clampSyncOffsetMs(offsetMs)
|
||||||
|
const safeTimelineMs = Math.max(0, Number(timelineMs) || 0)
|
||||||
|
const audioTargetMs = getAudioPositionMs(safeTimelineMs, safeOffsetMs)
|
||||||
|
const videoTargetMs = getVideoPositionMs(safeTimelineMs, safeOffsetMs)
|
||||||
|
|
||||||
|
if (audioPlayer) {
|
||||||
|
await audioPlayer.seekTo?.(audioTargetMs / 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (videoElRef.current && videoUri) {
|
||||||
|
videoElRef.current.currentTime = Math.max(0, videoTargetMs / 1000)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[audioPlayer, videoUri]
|
||||||
|
)
|
||||||
|
|
||||||
const stopPlayback = useCallback(async () => {
|
const stopPlayback = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
if (audioPlayer) await audioPlayer.pause?.()
|
if (audioPlayer) await audioPlayer.pause?.()
|
||||||
} catch { }
|
} catch {}
|
||||||
try {
|
try {
|
||||||
if (videoElRef.current && !videoElRef.current.paused) {
|
if (videoElRef.current && !videoElRef.current.paused) {
|
||||||
videoElRef.current.pause()
|
videoElRef.current.pause()
|
||||||
}
|
}
|
||||||
} catch { }
|
} catch {}
|
||||||
}, [audioPlayer])
|
}, [audioPlayer])
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
@@ -81,153 +106,166 @@ const RecordedPlayback = ({ route }) => {
|
|||||||
}
|
}
|
||||||
}, [videoUri])
|
}, [videoUri])
|
||||||
|
|
||||||
// Démarrage / arrêt
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
playbackEndedRef.current = false
|
playbackEndedRef.current = false
|
||||||
|
syncOffsetRef.current = initialSyncOffsetMs
|
||||||
|
setCurrentSyncOffsetMs(initialSyncOffsetMs)
|
||||||
|
|
||||||
const start = async () => {
|
const start = async () => {
|
||||||
try {
|
try {
|
||||||
if (videoElRef.current && videoUri && syncOffsetRef.current > 0) {
|
await alignPlaybackToTimeline(0, initialSyncOffsetMs)
|
||||||
videoElRef.current.currentTime = Math.max(0, syncOffsetRef.current)
|
|
||||||
}
|
|
||||||
if (audioPlayer && songUrl) {
|
if (audioPlayer && songUrl) {
|
||||||
await audioPlayer.play?.()
|
await audioPlayer.play?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (videoElRef.current && videoUri) {
|
if (videoElRef.current && videoUri) {
|
||||||
// Lecture vidéo HTML5 (muet pour éviter les policies)
|
|
||||||
videoElRef.current.muted = true
|
videoElRef.current.muted = true
|
||||||
videoElRef.current.play().catch(() => { })
|
videoElRef.current.play().catch(() => {})
|
||||||
}
|
}
|
||||||
} catch { }
|
} catch {}
|
||||||
}
|
}
|
||||||
start()
|
|
||||||
|
void start()
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
playbackEndedRef.current = false
|
playbackEndedRef.current = false
|
||||||
void stopPlayback()
|
void stopPlayback()
|
||||||
}
|
}
|
||||||
}, [audioPlayer, songUrl, stopPlayback, videoUri])
|
}, [alignPlaybackToTimeline, audioPlayer, initialSyncOffsetMs, songUrl, stopPlayback, videoUri])
|
||||||
|
|
||||||
// Boucle de progression + éventuelle sync de la vidéo si fournie
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = setInterval(() => {
|
const id = setInterval(() => {
|
||||||
try {
|
try {
|
||||||
const durS = toSeconds(audioPlayer?.duration) // secondes
|
const rawDurationMs = getCurrentRawAudioDurationMs()
|
||||||
const posS = toSeconds(audioPlayer?.currentTime) // secondes
|
const rawAudioMs = getCurrentRawAudioPositionMs()
|
||||||
|
const nextDurationMs = getTimelineDurationMs(rawDurationMs, syncOffsetRef.current)
|
||||||
|
const nextPositionMs = getTimelinePositionMs(rawAudioMs, syncOffsetRef.current)
|
||||||
|
const safePositionMs =
|
||||||
|
nextDurationMs > 0 ? Math.min(nextPositionMs, nextDurationMs) : nextPositionMs
|
||||||
|
const isVideoPlaying = !!(videoElRef.current && !videoElRef.current.paused)
|
||||||
|
const isPlaying = !!audioPlayer?.playing || isVideoPlaying
|
||||||
|
|
||||||
setProgress({
|
setProgressInfo({
|
||||||
posS,
|
pos: safePositionMs,
|
||||||
durS,
|
dur: nextDurationMs,
|
||||||
playing: !!audioPlayer?.playing,
|
isPlaying,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Sync vidéo si on a une source vidéo
|
|
||||||
if (videoElRef.current && videoUri && !Number.isNaN(videoElRef.current.currentTime)) {
|
if (videoElRef.current && videoUri && !Number.isNaN(videoElRef.current.currentTime)) {
|
||||||
const v = Number(videoElRef.current.currentTime || 0)
|
const expectedVideoMs = getVideoPositionMs(safePositionMs, syncOffsetRef.current)
|
||||||
const expected = Math.max(0, posS + (syncOffsetRef.current || 0))
|
const currentVideoMs = Number(videoElRef.current.currentTime || 0) * 1000
|
||||||
const drift = Math.abs(v - expected)
|
const driftMs = Math.abs(currentVideoMs - expectedVideoMs)
|
||||||
if (drift > 0.35) {
|
|
||||||
videoElRef.current.currentTime = expected
|
if (driftMs > 350) {
|
||||||
|
videoElRef.current.currentTime = Math.max(0, expectedVideoMs / 1000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch { }
|
} catch {}
|
||||||
}, 250)
|
}, 250)
|
||||||
|
|
||||||
return () => clearInterval(id)
|
return () => clearInterval(id)
|
||||||
}, [audioPlayer, videoUri])
|
}, [audioPlayer, getCurrentRawAudioDurationMs, getCurrentRawAudioPositionMs, videoUri])
|
||||||
|
|
||||||
// Slider: ratio 0..1
|
|
||||||
const sliderProgress = useMemo(() => {
|
|
||||||
return progress.durS > 0 ? Math.min(1, progress.posS / progress.durS) : 0
|
|
||||||
}, [progress])
|
|
||||||
const pendingSeekRef = useRef(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const duration = progress?.durS || 0
|
|
||||||
if (!duration) return
|
|
||||||
const position = progress?.posS || 0
|
|
||||||
if (playbackEndedRef.current && duration - position > 1) {
|
|
||||||
playbackEndedRef.current = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const remaining = Math.max(0, duration - position)
|
|
||||||
if (remaining <= 0.35 && !playbackEndedRef.current) {
|
|
||||||
playbackEndedRef.current = true
|
|
||||||
void (async () => {
|
|
||||||
await stopPlayback()
|
|
||||||
try {
|
|
||||||
if (audioPlayer) await audioPlayer.seekTo?.(0)
|
|
||||||
} catch { }
|
|
||||||
try {
|
|
||||||
if (videoElRef.current) {
|
|
||||||
videoElRef.current.currentTime = Math.max(0, syncOffsetRef.current || 0)
|
|
||||||
}
|
|
||||||
} catch { }
|
|
||||||
})()
|
|
||||||
}
|
|
||||||
}, [progress, stopPlayback, audioPlayer])
|
|
||||||
|
|
||||||
const onSeek = useCallback(
|
const onSeek = useCallback(
|
||||||
async (ratio) => {
|
async (targetMs) => {
|
||||||
const durS = Number(progress.durS || 0)
|
|
||||||
const target = durS > 0 ? durS * ratio : 0 // secondes
|
|
||||||
const promise = (async () => {
|
|
||||||
try {
|
try {
|
||||||
if (audioPlayer && durS > 0) {
|
const durationMs = progressInfo.dur || 0
|
||||||
await audioPlayer.seekTo?.(Math.max(0, target))
|
const safeTargetMs = Math.max(0, Math.min(durationMs, Math.floor(targetMs)))
|
||||||
}
|
await alignPlaybackToTimeline(safeTargetMs)
|
||||||
if (videoElRef.current && videoUri) {
|
} catch {}
|
||||||
const offset = syncOffsetRef.current || 0
|
|
||||||
videoElRef.current.currentTime = Math.max(0, target + offset)
|
|
||||||
}
|
|
||||||
} catch { }
|
|
||||||
})()
|
|
||||||
pendingSeekRef.current = promise
|
|
||||||
await promise
|
|
||||||
},
|
},
|
||||||
[audioPlayer, progress.durS, videoUri]
|
[alignPlaybackToTimeline, progressInfo.dur]
|
||||||
)
|
)
|
||||||
|
|
||||||
const waitForPendingSeek = useCallback(async () => {
|
const onSeekStart = useCallback(() => {
|
||||||
const promise = pendingSeekRef.current
|
playbackEndedRef.current = false
|
||||||
pendingSeekRef.current = null
|
|
||||||
if (!promise) return
|
|
||||||
try {
|
|
||||||
await promise
|
|
||||||
} catch { }
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const wasPlayingRef = useRef(false)
|
const pauseDuringSeek = useCallback(async () => {
|
||||||
const isSeekingRef = useRef(false)
|
|
||||||
const onSeekStart = useCallback(async () => {
|
|
||||||
try {
|
try {
|
||||||
if (isSeekingRef.current) return
|
|
||||||
isSeekingRef.current = true
|
|
||||||
wasPlayingRef.current = !!audioPlayer?.playing
|
|
||||||
pendingSeekRef.current = null
|
|
||||||
playbackEndedRef.current = false
|
|
||||||
if (audioPlayer?.playing) await audioPlayer.pause?.()
|
if (audioPlayer?.playing) await audioPlayer.pause?.()
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
if (videoElRef.current && !videoElRef.current.paused) {
|
if (videoElRef.current && !videoElRef.current.paused) {
|
||||||
videoElRef.current.pause()
|
videoElRef.current.pause()
|
||||||
}
|
}
|
||||||
} catch { }
|
} catch {}
|
||||||
}, [audioPlayer])
|
}, [audioPlayer])
|
||||||
const onSeekEnd = useCallback(async () => {
|
|
||||||
|
const resumeAfterSeek = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
if (!isSeekingRef.current) return
|
if (audioPlayer?.resume) {
|
||||||
await waitForPendingSeek()
|
await audioPlayer.resume?.()
|
||||||
isSeekingRef.current = false
|
} else if (audioPlayer) {
|
||||||
if (wasPlayingRef.current) {
|
await audioPlayer.play?.()
|
||||||
if (audioPlayer) await audioPlayer.resume?.()
|
|
||||||
if (videoElRef.current && videoUri) videoElRef.current.play().catch(() => { })
|
|
||||||
}
|
}
|
||||||
} catch { }
|
} catch {}
|
||||||
}, [audioPlayer, videoUri, waitForPendingSeek])
|
try {
|
||||||
|
if (videoElRef.current && videoUri) {
|
||||||
|
videoElRef.current.muted = true
|
||||||
|
videoElRef.current.play().catch(() => {})
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}, [audioPlayer, videoUri])
|
||||||
|
|
||||||
|
const handleSyncOffsetChange = useCallback(
|
||||||
|
(nextOffsetMs) => {
|
||||||
|
const safeOffsetMs = snapSyncOffsetMs(nextOffsetMs)
|
||||||
|
setCurrentSyncOffsetMs((prevOffsetMs) =>
|
||||||
|
prevOffsetMs === safeOffsetMs ? prevOffsetMs : safeOffsetMs
|
||||||
|
)
|
||||||
|
|
||||||
|
const previousOffsetMs = syncOffsetRef.current
|
||||||
|
if (safeOffsetMs === previousOffsetMs) return
|
||||||
|
|
||||||
|
playbackEndedRef.current = false
|
||||||
|
|
||||||
|
const rawAudioMs = getCurrentRawAudioPositionMs()
|
||||||
|
const rawDurationMs = getCurrentRawAudioDurationMs()
|
||||||
|
const currentTimelineMs = getTimelinePositionMs(rawAudioMs, previousOffsetMs)
|
||||||
|
const nextDurationMs = getTimelineDurationMs(rawDurationMs, safeOffsetMs)
|
||||||
|
const nextTimelineMs =
|
||||||
|
nextDurationMs > 0 ? Math.min(currentTimelineMs, nextDurationMs) : currentTimelineMs
|
||||||
|
const isVideoPlaying = !!(videoElRef.current && !videoElRef.current.paused)
|
||||||
|
|
||||||
|
syncOffsetRef.current = safeOffsetMs
|
||||||
|
|
||||||
|
setProgressInfo({
|
||||||
|
pos: nextTimelineMs,
|
||||||
|
dur: nextDurationMs,
|
||||||
|
isPlaying: !!audioPlayer?.playing || isVideoPlaying,
|
||||||
|
})
|
||||||
|
|
||||||
|
void alignPlaybackToTimeline(nextTimelineMs, safeOffsetMs)
|
||||||
|
},
|
||||||
|
[alignPlaybackToTimeline, audioPlayer?.playing, getCurrentRawAudioDurationMs, getCurrentRawAudioPositionMs]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const durationMs = progressInfo?.dur || 0
|
||||||
|
if (!durationMs) return
|
||||||
|
|
||||||
|
const positionMs = progressInfo?.pos || 0
|
||||||
|
if (playbackEndedRef.current && durationMs - positionMs > 1000) {
|
||||||
|
playbackEndedRef.current = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const remainingMs = Math.max(0, durationMs - positionMs)
|
||||||
|
if (remainingMs <= 400 && !playbackEndedRef.current) {
|
||||||
|
playbackEndedRef.current = true
|
||||||
|
void stopPlayback()
|
||||||
|
}
|
||||||
|
}, [progressInfo, stopPlayback])
|
||||||
|
|
||||||
const handleTogglePlayback = useCallback(async () => {
|
const handleTogglePlayback = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const duration = Number(progress?.durS || 0)
|
const durationMs = progressInfo?.dur || 0
|
||||||
const position = Number(progress?.posS || 0)
|
const positionMs = progressInfo?.pos || 0
|
||||||
const isAtEnd = duration > 0 && duration - position < 0.35
|
const isAtEnd = durationMs > 0 && durationMs - positionMs < 1000
|
||||||
const isCurrentlyPlaying = !!audioPlayer?.playing
|
const isVideoPlaying = !!(videoElRef.current && !videoElRef.current.paused)
|
||||||
|
const isCurrentlyPlaying = !!audioPlayer?.playing || isVideoPlaying
|
||||||
|
|
||||||
if (isCurrentlyPlaying) {
|
if (isCurrentlyPlaying) {
|
||||||
playbackEndedRef.current = false
|
playbackEndedRef.current = false
|
||||||
@@ -236,22 +274,25 @@ const RecordedPlayback = ({ route }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isAtEnd) {
|
if (isAtEnd) {
|
||||||
if (audioPlayer) await audioPlayer.seekTo?.(0)
|
await alignPlaybackToTimeline(0)
|
||||||
if (videoElRef.current && videoUri) {
|
|
||||||
videoElRef.current.currentTime = Math.max(0, syncOffsetRef.current || 0)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
playbackEndedRef.current = false
|
playbackEndedRef.current = false
|
||||||
|
|
||||||
if (songUrl && audioPlayer) {
|
if (songUrl && audioPlayer) {
|
||||||
|
if (audioPlayer?.resume) {
|
||||||
|
await audioPlayer.resume?.()
|
||||||
|
} else {
|
||||||
await audioPlayer.play?.()
|
await audioPlayer.play?.()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (videoElRef.current && videoUri) {
|
if (videoElRef.current && videoUri) {
|
||||||
videoElRef.current.muted = true
|
videoElRef.current.muted = true
|
||||||
videoElRef.current.play().catch(() => { })
|
videoElRef.current.play().catch(() => {})
|
||||||
}
|
}
|
||||||
} catch { }
|
} catch {}
|
||||||
}, [audioPlayer, songUrl, stopPlayback, progress, videoUri])
|
}, [alignPlaybackToTimeline, audioPlayer, progressInfo, songUrl, stopPlayback, videoUri])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page
|
<Page
|
||||||
@@ -273,7 +314,6 @@ const RecordedPlayback = ({ route }) => {
|
|||||||
width: '100%',
|
width: '100%',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Bloc vidéo optionnel si jamais tu as un videoUri sur web */}
|
|
||||||
{videoUri ? (
|
{videoUri ? (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={handleTogglePlayback}
|
onPress={handleTogglePlayback}
|
||||||
@@ -301,7 +341,7 @@ const RecordedPlayback = ({ route }) => {
|
|||||||
}}
|
}}
|
||||||
controls={false}
|
controls={false}
|
||||||
/>
|
/>
|
||||||
{!progress.playing && (
|
{!progressInfo.isPlaying && (
|
||||||
<View
|
<View
|
||||||
pointerEvents="none"
|
pointerEvents="none"
|
||||||
style={{
|
style={{
|
||||||
@@ -324,7 +364,6 @@ const RecordedPlayback = ({ route }) => {
|
|||||||
)}
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
) : (
|
) : (
|
||||||
// Placeholder quand pas de vidéo sur web
|
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
width: isWeb ? WEB_PREVIEW_WIDTH : '80%',
|
width: isWeb ? WEB_PREVIEW_WIDTH : '80%',
|
||||||
@@ -349,21 +388,25 @@ const RecordedPlayback = ({ route }) => {
|
|||||||
width: isWeb ? WEB_PREVIEW_WIDTH : '80%',
|
width: isWeb ? WEB_PREVIEW_WIDTH : '80%',
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
alignSelf: 'center',
|
alignSelf: 'center',
|
||||||
marginTop: 6,
|
marginTop: 12,
|
||||||
alignItems: 'center',
|
gap: 18,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View style={{ width: '100%' }}>
|
<ProgressSlider
|
||||||
<Slider
|
positionMs={progressInfo.pos}
|
||||||
value={fmtSeconds(progress.posS)} // mm:ss (seconds)
|
durationMs={progressInfo.dur}
|
||||||
maxValue={fmtSeconds(progress.durS)} // mm:ss (seconds)
|
isPlaying={progressInfo.isPlaying}
|
||||||
progress={sliderProgress}
|
|
||||||
seekEnabled={!!songUrl}
|
|
||||||
onSeek={onSeek}
|
onSeek={onSeek}
|
||||||
onSeekStart={onSeekStart}
|
onSeekStart={onSeekStart}
|
||||||
onSeekEnd={onSeekEnd}
|
onPause={pauseDuringSeek}
|
||||||
|
onPlay={resumeAfterSeek}
|
||||||
|
disabled={!songUrl}
|
||||||
|
/>
|
||||||
|
<SyncOffsetSlider
|
||||||
|
valueMs={currentSyncOffsetMs}
|
||||||
|
onChange={handleSyncOffsetChange}
|
||||||
|
disabled={!songUrl}
|
||||||
/>
|
/>
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -382,11 +425,12 @@ const RecordedPlayback = ({ route }) => {
|
|||||||
shouldPreserveBlobRef.current = true
|
shouldPreserveBlobRef.current = true
|
||||||
try {
|
try {
|
||||||
await stopPlayback()
|
await stopPlayback()
|
||||||
} catch { }
|
} catch {}
|
||||||
navigate(Routes.PlaybackDownload, {
|
navigate(Routes.PlaybackDownload, {
|
||||||
action: 'playback',
|
action: 'playback',
|
||||||
uri: videoUri || null, // peut être null sur web
|
uri: videoUri || null,
|
||||||
project,
|
project,
|
||||||
|
syncOffsetMs: syncOffsetRef.current,
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -395,7 +439,7 @@ const RecordedPlayback = ({ route }) => {
|
|||||||
onPress={async () => {
|
onPress={async () => {
|
||||||
try {
|
try {
|
||||||
await stopPlayback()
|
await stopPlayback()
|
||||||
} catch { }
|
} catch {}
|
||||||
navigate(Routes.RecordPlayback, { project })
|
navigate(Routes.RecordPlayback, { project })
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import CreateLyricsHeader from '../Writing/components/CreateLyricsHeader'
|
|||||||
import { Image as ExpoImage } from 'expo-image'
|
import { Image as ExpoImage } from 'expo-image'
|
||||||
import SubscriptionConfirmModal from '../../components/SubscriptionConfirmModal'
|
import SubscriptionConfirmModal from '../../components/SubscriptionConfirmModal'
|
||||||
import { isWeb } from '../../hooks/useLayoutType'
|
import { isWeb } from '../../hooks/useLayoutType'
|
||||||
|
import { clampSyncOffsetMs, toSyncOffsetSeconds } from '../../utils/playbackSync'
|
||||||
|
|
||||||
const triggerDownload = async (url, title) => {
|
const triggerDownload = async (url, title) => {
|
||||||
if (!url) return
|
if (!url) return
|
||||||
@@ -144,13 +145,15 @@ const PlaybackDownload = ({ route }) => {
|
|||||||
const { currentUID, selectedProject } = useUserData()
|
const { currentUID, selectedProject } = useUserData()
|
||||||
const { hasActiveSubscription, hasPurchased, videos } = useUser() || {}
|
const { hasActiveSubscription, hasPurchased, videos } = useUser() || {}
|
||||||
const { createPlaybackDownloadCheckout } = useStripe()
|
const { createPlaybackDownloadCheckout } = useStripe()
|
||||||
const { action: routeAction, uri, project: routeProject } = route.params || {}
|
const { action: routeAction, uri, project: routeProject, syncOffsetMs: routeSyncOffsetMs = 0 } = route.params || {}
|
||||||
const action = routeAction || 'playback'
|
const action = routeAction || 'playback'
|
||||||
|
const syncOffsetMs = clampSyncOffsetMs(routeSyncOffsetMs)
|
||||||
console.log('[PlaybackDownload] route params', {
|
console.log('[PlaybackDownload] route params', {
|
||||||
action,
|
action,
|
||||||
projectId: routeProject?.id,
|
projectId: routeProject?.id,
|
||||||
hasUri: Boolean(uri),
|
hasUri: Boolean(uri),
|
||||||
currentUID,
|
currentUID,
|
||||||
|
syncOffsetMs,
|
||||||
})
|
})
|
||||||
const { setIsLoading, setTooltip } = useMinuit()
|
const { setIsLoading, setTooltip } = useMinuit()
|
||||||
const [isAfterPlaybackVideoVisible, setIsAfterPlaybackVideoVisible] = useState(false)
|
const [isAfterPlaybackVideoVisible, setIsAfterPlaybackVideoVisible] = useState(false)
|
||||||
@@ -259,6 +262,7 @@ const PlaybackDownload = ({ route }) => {
|
|||||||
videoUrl,
|
videoUrl,
|
||||||
audioUrl,
|
audioUrl,
|
||||||
storagePath: `users/${currentUID}/projects/${projectForDownload.id}/playback.mp4`,
|
storagePath: `users/${currentUID}/projects/${projectForDownload.id}/playback.mp4`,
|
||||||
|
syncOffset: toSyncOffsetSeconds(syncOffsetMs),
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[PlaybackDownload] calling upload-mergeVideoAndAudio', payload)
|
console.log('[PlaybackDownload] calling upload-mergeVideoAndAudio', payload)
|
||||||
@@ -297,7 +301,7 @@ const PlaybackDownload = ({ route }) => {
|
|||||||
releaseBlobUrl(uri || null)
|
releaseBlobUrl(uri || null)
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
}, [action, currentUID, pendingPlaybackUrl, projectForDownload, resolveAudioUrl, setIsLoading, uri])
|
}, [action, currentUID, pendingPlaybackUrl, projectForDownload, resolveAudioUrl, setIsLoading, syncOffsetMs, uri])
|
||||||
|
|
||||||
const handleDownloadUri = useCallback(async () => {
|
const handleDownloadUri = useCallback(async () => {
|
||||||
if (isPublishing || isDownloading) return
|
if (isPublishing || isDownloading) return
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import DeleteAccountModal from '../components/modal/DeleteAccountModal'
|
|||||||
import DeletePlaybackModal from '../components/modal/DeletePlaybackModal'
|
import DeletePlaybackModal from '../components/modal/DeletePlaybackModal'
|
||||||
import DeleteAudioModal from '../components/modal/DeleteAudioModal'
|
import DeleteAudioModal from '../components/modal/DeleteAudioModal'
|
||||||
import PlaylistModal from '../components/modal/PlaylistModal'
|
import PlaylistModal from '../components/modal/PlaylistModal'
|
||||||
|
import PlaylistAddTracksModal from '../components/modal/PlaylistAddTracksModal'
|
||||||
import PlaybackPickerModal from '../components/modal/PlaybackPickerModal'
|
import PlaybackPickerModal from '../components/modal/PlaybackPickerModal'
|
||||||
import ShareModal from '../components/modal/ShareModal'
|
import ShareModal from '../components/modal/ShareModal'
|
||||||
import ReportModal from '../components/modal/ReportModal'
|
import ReportModal from '../components/modal/ReportModal'
|
||||||
@@ -16,6 +17,7 @@ registerSheet('DeleteAccount', DeleteAccountModal)
|
|||||||
registerSheet('DeletePlayback', DeletePlaybackModal)
|
registerSheet('DeletePlayback', DeletePlaybackModal)
|
||||||
registerSheet('DeleteAudio', DeleteAudioModal)
|
registerSheet('DeleteAudio', DeleteAudioModal)
|
||||||
registerSheet('Playlist', PlaylistModal)
|
registerSheet('Playlist', PlaylistModal)
|
||||||
|
registerSheet('PlaylistAddTracks', PlaylistAddTracksModal)
|
||||||
registerSheet('PlaybackPicker', PlaybackPickerModal)
|
registerSheet('PlaybackPicker', PlaybackPickerModal)
|
||||||
registerSheet('Share', ShareModal)
|
registerSheet('Share', ShareModal)
|
||||||
registerSheet('Report', ReportModal)
|
registerSheet('Report', ReportModal)
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
export const MAX_SYNC_OFFSET_MS = 2000
|
||||||
|
export const SYNC_OFFSET_STEP_MS = 50
|
||||||
|
|
||||||
|
const toFiniteNumber = (value) => {
|
||||||
|
const numericValue = Number(value ?? 0)
|
||||||
|
return Number.isFinite(numericValue) ? numericValue : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export const clampSyncOffsetMs = (value, maxOffsetMs = MAX_SYNC_OFFSET_MS) => {
|
||||||
|
const safeMax = Math.max(0, toFiniteNumber(maxOffsetMs))
|
||||||
|
const safeValue = toFiniteNumber(value)
|
||||||
|
return Math.min(safeMax, Math.max(-safeMax, safeValue))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const snapSyncOffsetMs = (
|
||||||
|
value,
|
||||||
|
stepMs = SYNC_OFFSET_STEP_MS,
|
||||||
|
maxOffsetMs = MAX_SYNC_OFFSET_MS
|
||||||
|
) => {
|
||||||
|
const safeStep = Math.max(1, Math.round(toFiniteNumber(stepMs) || 1))
|
||||||
|
const clampedValue = clampSyncOffsetMs(value, maxOffsetMs)
|
||||||
|
return clampSyncOffsetMs(Math.round(clampedValue / safeStep) * safeStep, maxOffsetMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getSyncTrimMs = (syncOffsetMs = 0) => {
|
||||||
|
const safeOffsetMs = clampSyncOffsetMs(syncOffsetMs)
|
||||||
|
return {
|
||||||
|
audioTrimMs: safeOffsetMs < 0 ? Math.abs(safeOffsetMs) : 0,
|
||||||
|
videoTrimMs: safeOffsetMs > 0 ? safeOffsetMs : 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getTimelinePositionMs = (audioPositionMs = 0, syncOffsetMs = 0) => {
|
||||||
|
const { audioTrimMs } = getSyncTrimMs(syncOffsetMs)
|
||||||
|
return Math.max(0, toFiniteNumber(audioPositionMs) - audioTrimMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getTimelineDurationMs = (audioDurationMs = 0, syncOffsetMs = 0) => {
|
||||||
|
const { audioTrimMs } = getSyncTrimMs(syncOffsetMs)
|
||||||
|
return Math.max(0, toFiniteNumber(audioDurationMs) - audioTrimMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getAudioPositionMs = (timelinePositionMs = 0, syncOffsetMs = 0) => {
|
||||||
|
const { audioTrimMs } = getSyncTrimMs(syncOffsetMs)
|
||||||
|
return Math.max(0, toFiniteNumber(timelinePositionMs) + audioTrimMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getVideoPositionMs = (timelinePositionMs = 0, syncOffsetMs = 0) => {
|
||||||
|
const { videoTrimMs } = getSyncTrimMs(syncOffsetMs)
|
||||||
|
return Math.max(0, toFiniteNumber(timelinePositionMs) + videoTrimMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formatSyncOffsetMs = (value) => {
|
||||||
|
const safeValue = snapSyncOffsetMs(value)
|
||||||
|
if (safeValue === 0) return '0 ms'
|
||||||
|
return `${safeValue > 0 ? '+' : ''}${safeValue} ms`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toSyncOffsetSeconds = (value) => clampSyncOffsetMs(value) / 1000
|
||||||
Reference in New Issue
Block a user