Files
musicland/src/screens/Playback/RecordedPlayback.js
T
2026-03-04 15:07:43 +01:00

279 lines
9.1 KiB
JavaScript

import { useFocusEffect } from '@react-navigation/native'
import useSharedAudioPlayer from '../../hooks/useSharedAudioPlayer'
import * as FileSystem from 'expo-file-system'
import { VideoView, useVideoPlayer } from 'expo-video'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Image, Pressable, View } from 'react-native'
import { background, icons } from '../../assets'
import BorderGradientButton from '../../components/BorderGradientButton'
import GradientButton from '../../components/GradientButton'
import MusicLandHeader from '../../components/MusicLandHeader'
import ProgressSlider from '../../components/player/ProgressSlider'
import Page from '../../layouts/Page'
import { Routes } from '../../navigation'
import { goBack, navigate } from '../../navigation/NavigationService'
import { gutters, Palette } from '../../styles'
import { FONT_FAMILY } from '../../styles/Fonts'
const RecordedPlayback = ({ route }) => {
const { videoUri, project, syncOffsetMs = 0 } = route.params || {}
const songUrl = project?.songUrl || null
const audioPlayer = useSharedAudioPlayer(songUrl ? { uri: songUrl } : undefined, {
id: project?.id ? `recorded-${project.id}` : songUrl ? `recorded-${songUrl}` : undefined,
title: typeof project?.title === 'string' ? project.title : 'Sans titre',
artist: typeof project?.userName === 'string' ? project.userName : 'MusicLand',
artwork: project?.coverUrl || null,
coverUrl: project?.coverUrl || null,
metadata: { projectId: project?.id, screen: 'RecordedPlayback' },
})
const videoPlayer = useVideoPlayer(videoUri || null, (p) => {
p.loop = false
p.muted = true // recorded video has no audio; keep muted anyway
p.timeUpdateEventInterval = 0.2
})
const [progressInfo, setProgressInfo] = useState({
pos: 0,
dur: 0,
isPlaying: false,
})
const playbackEndedRef = useRef(false)
const syncOffsetRef = useRef(Math.max(0, Number(syncOffsetMs) || 0))
// Start both players on mount
const stopPlayback = useCallback(async () => {
try {
if (audioPlayer) await audioPlayer.pause?.()
} catch (e) { }
try {
videoPlayer?.pause?.()
} catch (e) { }
}, [audioPlayer, videoPlayer])
useFocusEffect(
useCallback(() => {
return () => {
void stopPlayback()
}
}, [stopPlayback])
)
useEffect(() => {
playbackEndedRef.current = false
const start = async () => {
try {
if (videoPlayer && syncOffsetRef.current > 0) {
videoPlayer.currentTime = syncOffsetRef.current / 1000
}
if (audioPlayer && songUrl) await audioPlayer.play?.()
if (videoPlayer) videoPlayer.play()
} catch (e) { }
}
start()
return () => {
playbackEndedRef.current = false
void stopPlayback()
}
}, [audioPlayer, songUrl, stopPlayback, videoPlayer])
// Poll from audio player for progress display; keep video in sync if drifting
useEffect(() => {
const id = global.setInterval(() => {
try {
const dur = (audioPlayer?.duration || 0) * 1000
const pos = (audioPlayer?.currentTime || 0) * 1000
const playing = !!audioPlayer?.playing || !!videoPlayer?.playing
setProgressInfo({ pos, dur, isPlaying: playing })
// basic drift correction: if desync > 300ms, align video
if (videoPlayer && !Number.isNaN(videoPlayer.currentTime)) {
const offset = syncOffsetRef.current || 0
const expected = Math.max(0, (pos || 0) + offset)
const v = (videoPlayer.currentTime || 0) * 1000
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) => {
try {
const dur = progressInfo.dur || 0
const pos = Math.max(0, Math.min(dur, Math.floor(targetMs)))
if (audioPlayer && dur > 0) await audioPlayer.seekTo?.(Math.floor(pos / 1000))
if (videoPlayer) {
const offset = syncOffsetRef.current || 0
videoPlayer.currentTime = Math.max(0, (pos + offset) / 1000)
}
} catch (e) { }
}
const onSeekStart = useCallback(() => {
playbackEndedRef.current = false
}, [])
const pauseDuringSeek = useCallback(async () => {
try {
if (audioPlayer?.playing) await audioPlayer.pause?.()
} catch (e) { }
try {
if (videoPlayer?.playing) videoPlayer.pause()
} catch (e) { }
}, [audioPlayer, videoPlayer])
const resumeAfterSeek = useCallback(async () => {
try {
if (audioPlayer?.resume) {
await audioPlayer.resume?.()
} else if (audioPlayer) {
await audioPlayer.play?.()
}
} catch (e) { }
try {
if (videoPlayer) videoPlayer.play()
} catch (e) { }
}, [audioPlayer, videoPlayer])
const sliderProgress = useMemo(() => {
return progressInfo.dur ? Math.min(1, (progressInfo.pos || 0) / progressInfo.dur) : 0
}, [progressInfo])
useEffect(() => {
const duration = progressInfo?.dur || 0
if (!duration) return
const position = progressInfo?.pos || 0
if (playbackEndedRef.current && duration - position > 1000) {
playbackEndedRef.current = false
return
}
const remaining = Math.max(0, duration - position)
if (remaining <= 400 && !playbackEndedRef.current) {
playbackEndedRef.current = true
void stopPlayback()
}
}, [progressInfo, stopPlayback])
const handleTogglePlayback = async () => {
try {
const duration = progressInfo?.dur || 0
const position = progressInfo?.pos || 0
const isAtEnd = duration > 0 && duration - position < 1000
const isCurrentlyPlaying = !!audioPlayer?.playing || !!videoPlayer?.playing
if (isCurrentlyPlaying) {
playbackEndedRef.current = false
if (audioPlayer?.playing) await audioPlayer.pause?.()
if (videoPlayer?.playing) videoPlayer.pause()
return
}
if (isAtEnd) {
if (audioPlayer) await audioPlayer.seekTo?.(0)
if (videoPlayer) {
const offset = syncOffsetRef.current || 0
videoPlayer.currentTime = Math.max(0, offset / 1000)
}
}
playbackEndedRef.current = false
if (songUrl && audioPlayer) await audioPlayer.play?.()
if (videoPlayer) videoPlayer.play()
} catch (e) { }
}
return (
<Page backgroundColor={Palette.grayMid} headerType="NONE">
<MusicLandHeader progress={19} onPressBack={goBack} />
<View style={{ flex: 1, paddingTop: 12, gap: 14, paddingBottom: gutters * 2 }}>
<View style={{ flex: 1, gap: 22 }}>
{!!videoUri && (
<VideoView
player={videoPlayer}
nativeControls={false}
contentFit="contain"
style={{
width: '80%',
flex: 1,
alignSelf: 'center',
borderRadius: 16,
backgroundColor: '#00000066',
overflow: 'hidden',
}}
/>
)}
<ProgressSlider
positionMs={progressInfo.pos}
durationMs={progressInfo.dur}
isPlaying={progressInfo.isPlaying}
onSeek={onSeek}
onSeekStart={onSeekStart}
onPause={pauseDuringSeek}
onPlay={resumeAfterSeek}
disabled={!songUrl}
/>
<Pressable
onPress={handleTogglePlayback}
style={{
width: 72,
height: 72,
borderRadius: 36,
alignSelf: 'center',
marginTop: 4,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(10, 5, 24, 0.65)',
borderWidth: 1,
borderColor: '#F94697',
}}
>
<Image
source={progressInfo.isPlaying ? icons.pause : icons.play}
style={{ width: 26, height: 26 }}
/>
</Pressable>
</View>
<View style={{ width: '80%', alignSelf: 'center', marginTop: 4, gap: 12 }}>
<GradientButton
title="Je valide"
onPress={async () => {
try {
await stopPlayback()
} catch (e) { }
navigate(Routes.PlaybackDownload, {
action: 'playback',
uri: videoUri,
project,
})
}}
/>
<BorderGradientButton
title="Recommencer"
onPress={async () => {
try {
await stopPlayback()
} catch (e) { }
try {
if (videoUri) {
const info = await FileSystem.getInfoAsync(videoUri)
if (info?.exists)
await FileSystem.deleteAsync(videoUri, {
idempotent: true,
})
}
} catch (e) { }
navigate(Routes.RecordPlayback, { project })
}}
/>
</View>
</View>
</Page>
)
}
export default RecordedPlayback