offset audio after playback and add songs
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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,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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user