offset audio after playback and add songs

This commit is contained in:
Thomas Demirdjian
2026-03-12 10:58:43 +01:00
parent fd299e8543
commit ebdf9862a7
25 changed files with 4794 additions and 449 deletions
+7 -2
View File
@@ -15,6 +15,7 @@ export default ({
maxValue,
progress,
onSeek,
onValueChange,
onSeekStart,
onSeekEnd,
seekEnabled = false,
@@ -42,9 +43,13 @@ export default ({
const handleValueChange = useCallback(
(nextValue) => {
if (!seekEnabled) return
setSliderValue(clamp01(nextValue))
const ratio = clamp01(nextValue)
setSliderValue(ratio)
if (typeof onValueChange === 'function') {
onValueChange(ratio)
}
},
[seekEnabled]
[onValueChange, seekEnabled]
)
const handleSlidingComplete = useCallback(
+13 -5
View File
@@ -14,6 +14,7 @@ const Slider = ({
maxValue,
progress,
onSeek,
onValueChange,
onSeekStart,
onSeekEnd,
seekEnabled = false,
@@ -34,11 +35,12 @@ const Slider = ({
const available = Math.max(layoutWidth - INITIAL_BOX_SIZE, 0)
if (available <= 0) {
setRatio(0)
return
return 0
}
const clampedX = Math.min(Math.max(x, 0), layoutWidth)
const nextRatio = clamp01(clampedX / available)
setRatio(nextRatio)
return nextRatio
},
[layoutWidth]
)
@@ -47,20 +49,26 @@ const Slider = ({
(event) => {
if (!seekEnabled) return
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') {
onSeekStart()
}
},
[seekEnabled, updateRatioFromX, onSeekStart]
[seekEnabled, updateRatioFromX, onSeekStart, onValueChange]
)
const handleMove = useCallback(
(event) => {
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(() => {
@@ -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 -5
View File
@@ -4,9 +4,8 @@ import { SheetManager } from 'react-native-actions-sheet'
import useMinuit from 'react-native-minuit/src/hooks/useMinuit'
import SwiperFlatList from 'react-native-swiper-flatlist'
import { icons } from '../../assets'
import { arrayUnion, playlistsRef } from '../../config/firebase'
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 { FONT_FAMILY } from '../../styles/Fonts'
import Style, { size } from '../../styles/Style'
@@ -112,9 +111,9 @@ const PlaylistModal = (props) => {
const projectId = props?.payload?.projectId
if (!selectedId) return
if (projectId) {
await playlistsRef.doc(selectedId).update({
musics: arrayUnion(projectId),
updatedAt: new Date(),
await addMusicToPlaylist({
playlistId: selectedId,
projectId,
})
const addedPlaylist = sanitizedPlaylists.find((p) => p?.id === selectedId)
const name = addedPlaylist?.name || 'la playlist'
+4 -5
View File
@@ -6,9 +6,8 @@ import { SheetManager, useProviderContext } from 'react-native-actions-sheet'
import { actionSheetEventManager } from 'react-native-actions-sheet/dist/src/eventmanager'
import useMinuit from 'react-native-minuit/src/hooks/useMinuit'
import { icons } from '../../assets'
import { arrayUnion, playlistsRef } from '../../config/firebase'
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 { FONT_FAMILY } from '../../styles/Fonts'
import Style, { size } from '../../styles/Style'
@@ -64,9 +63,9 @@ const PlaylistModal = (props) => {
try {
const projectId = props?.payload?.projectId
if (projectId) {
await playlistsRef.doc(selectedId).update({
musics: arrayUnion(projectId),
updatedAt: new Date(),
await addMusicToPlaylist({
playlistId: selectedId,
projectId,
})
const addedPlaylist = sanitizedPlaylists.find((p) => p?.id === selectedId)
const name = addedPlaylist?.name || 'la playlist'
+85
View File
@@ -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