From a5a5806c7c43d88ab97e52e6d7eaa206a7d5c1b0 Mon Sep 17 00:00:00 2001 From: leon-morival Date: Tue, 7 Oct 2025 10:14:31 +0200 Subject: [PATCH] audio player --- src/components/player/GlobalAudioPlayer.js | 57 ++-- src/screens/Library/MusicDetails.js | 293 +++++++++++++-------- 2 files changed, 203 insertions(+), 147 deletions(-) diff --git a/src/components/player/GlobalAudioPlayer.js b/src/components/player/GlobalAudioPlayer.js index 5e53590..2ac8fc8 100644 --- a/src/components/player/GlobalAudioPlayer.js +++ b/src/components/player/GlobalAudioPlayer.js @@ -1,13 +1,6 @@ import { BlurView } from "expo-blur"; import React, { useMemo } from "react"; -import { - Image, - Platform, - Pressable, - StyleSheet, - Text, - View, -} from "react-native"; +import { Image, Pressable, StyleSheet, Text, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { icons, img } from "../../assets"; @@ -60,14 +53,12 @@ const GlobalAudioPlayer = () => { return ( @@ -79,17 +70,6 @@ const GlobalAudioPlayer = () => { {artist} )} - - - - - - {formatDuration(positionMs)} - {formatDuration(durationMs)} - - { const projectId = params?.projectId || null; const [fav, setFav] = useState(false); const [currentUID] = useGlobal("currentUID"); - const [isPlaying, setIsPlaying] = useState(false); - const [progressInfo, setProgressInfo] = useState({ pos: 0, dur: 0 }); - const wasPlayingBeforeSeek = React.useRef(false); - const listenedMsRef = React.useRef(0); - const incrementDoneRef = React.useRef(false); - const timerRef = React.useRef(null); + const wasPlayingBeforeSeek = useRef(false); + const listenedMsRef = useRef(0); + const incrementDoneRef = useRef(false); + const timerRef = useRef(null); const { data: project } = useDataFromRef({ ref: projectId ? projectsRef.doc(projectId) : null, @@ -75,14 +73,43 @@ const MusicDetails = ({ route }) => { const coverUrl = project?.coverUrl || null; const songUrl = project?.songUrl || null; - const player = useSharedAudioPlayer(songUrl ? { uri: songUrl } : undefined, { - id: projectId ? `project-${projectId}` : songUrl ? `song-${songUrl}` : undefined, - title, - artist, - artwork: coverUrl, - coverUrl, - metadata: { projectId, screen: "MusicDetails" }, - }); + const trackId = useMemo(() => { + if (projectId) return `project-${projectId}`; + if (songUrl) return `song-${songUrl}`; + return null; + }, [projectId, songUrl]); + + const trackDescriptor = useMemo(() => { + if (!trackId || !songUrl) return null; + return { + id: trackId, + uri: songUrl, + songUrl, + title, + artist, + artwork: coverUrl, + coverUrl, + metadata: { projectId, screen: "MusicDetails" }, + context: { projectId, screen: "MusicDetails" }, + }; + }, [trackId, songUrl, title, artist, coverUrl, projectId]); + + const { + isCurrent: isCurrentTrack, + isPlaying: isTrackPlaying, + positionMs, + durationMs, + ensureLoaded, + pause: pauseTrack, + resume: resumeTrack, + seekTo: seekTrackTo, + seekBy: seekTrackBy, + } = useTrackController(trackDescriptor); + + const progressInfo = useMemo( + () => ({ pos: positionMs, dur: durationMs }), + [positionMs, durationMs] + ); // Karaoke aligned words (from timestamps) const alignedWords = useMemo(() => { @@ -104,18 +131,7 @@ const MusicDetails = ({ route }) => { useEffect(() => { listenedMsRef.current = 0; incrementDoneRef.current = false; - }, [songUrl]); - - // Poll player state to update progress and play state - useEffect(() => { - const id = setInterval(() => { - const dur = (player?.duration || 0) * 1000; - const pos = (player?.currentTime || 0) * 1000; - setProgressInfo({ pos, dur }); - setIsPlaying(!!player?.playing); - }, 300); - return () => clearInterval(id); - }, [player]); + }, [trackId]); // Start/stop a timer to accumulate listened milliseconds while playing useEffect(() => { @@ -125,7 +141,7 @@ const MusicDetails = ({ route }) => { timerRef.current = null; } }; - if (isPlaying && projectId) { + if (isTrackPlaying && projectId) { if (!timerRef.current) { timerRef.current = global.setInterval(async () => { try { @@ -149,7 +165,7 @@ const MusicDetails = ({ route }) => { clearTimer(); } return clearTimer; - }, [isPlaying, projectId]); + }, [isTrackPlaying, projectId]); const fmt = (ms) => { const total = Math.max(0, Math.floor((ms || 0) / 1000)); @@ -160,66 +176,145 @@ const MusicDetails = ({ route }) => { return `${m}:${s}`; }; - const togglePlay = async () => { - if (!player || !songUrl) return; + const handleTogglePlay = useCallback(async () => { + if (!trackDescriptor) return; try { - if (player.playing) { - await player.pause?.(); - setIsPlaying(false); - } else { - await player.play?.(); - setIsPlaying(true); - } - } catch (e) { - console.log("MusicDetails audio error", e?.message); - } - }; - - const onSeek = async (ratio) => { - try { - const dur = progressInfo.dur || 0; - const pos = Math.floor(dur * ratio); - if (player && dur > 0) { - await player.seekTo?.(Math.floor((pos || 0) / 1000)); - } - } catch (e) { - console.log("MusicDetails seek error", e?.message); - } - }; - - const seekBy = async (deltaSeconds) => { - try { - const cur = Math.floor((progressInfo.pos || 0) / 1000); - const next = Math.max(0, cur + deltaSeconds); - if (player) await player.seekTo?.(next); - } catch (e) { - console.log("MusicDetails seekBy error", e?.message); - } - }; - - const handleLyricsSeek = React.useCallback( - async (timestampS) => { - if (!player || typeof timestampS !== "number" || Number.isNaN(timestampS)) + if (!isCurrentTrack) { + await ensureLoaded({ startPositionMs: progressInfo.pos, autoPlay: true }); return; - const seconds = Math.max(0, Number(timestampS) || 0); - const duration = Number(player?.duration || 0); - const bounded = duration > 0 ? Math.min(seconds, duration) : seconds; + } + if (isTrackPlaying) { + await pauseTrack(); + } else { + await resumeTrack(); + } + } catch (e) { + console.log("MusicDetails toggle error", e?.message); + } + }, [ + trackDescriptor, + isCurrentTrack, + ensureLoaded, + progressInfo.pos, + isTrackPlaying, + pauseTrack, + resumeTrack, + ]); + + const handleSliderSeekStart = useCallback(async () => { + if (!trackDescriptor) return; + try { + wasPlayingBeforeSeek.current = isTrackPlaying; + if (!isCurrentTrack) { + await ensureLoaded({ + startPositionMs: progressInfo.pos, + autoPlay: false, + }); + } + if (isTrackPlaying) { + await pauseTrack(); + } + } catch (e) { + console.log("MusicDetails seek start error", e?.message); + } + }, [ + trackDescriptor, + isTrackPlaying, + isCurrentTrack, + ensureLoaded, + progressInfo.pos, + pauseTrack, + ]); + + const handleSliderSeek = useCallback( + async (ratio) => { + const dur = progressInfo.dur || 0; + if (!trackDescriptor || dur <= 0) return; + const targetMs = Math.max(0, Math.floor(dur * ratio)); + try { + if (!isCurrentTrack) { + await ensureLoaded({ startPositionMs: targetMs, autoPlay: false }); + } else { + await seekTrackTo(targetMs); + } + } catch (e) { + console.log("MusicDetails seek error", e?.message); + } + }, + [progressInfo.dur, trackDescriptor, isCurrentTrack, ensureLoaded, seekTrackTo] + ); + + const handleSliderSeekEnd = useCallback(async () => { + try { + if (wasPlayingBeforeSeek.current) { + if (!isCurrentTrack) { + await ensureLoaded({ startPositionMs: progressInfo.pos, autoPlay: true }); + } else { + await resumeTrack(); + } + } + } catch (e) { + console.log("MusicDetails seek end error", e?.message); + } finally { + wasPlayingBeforeSeek.current = false; + } + }, [ensureLoaded, isCurrentTrack, progressInfo.pos, resumeTrack]); + + const handleSeekBySeconds = useCallback( + async (deltaSeconds) => { + if (!trackDescriptor) return; + const deltaMs = Number(deltaSeconds || 0) * 1000; + const target = Math.max(0, (progressInfo.pos || 0) + deltaMs); + try { + if (!isCurrentTrack) { + await ensureLoaded({ startPositionMs: target, autoPlay: true }); + } else { + await seekTrackBy(deltaMs); + } + } catch (e) { + console.log("MusicDetails seekBy error", e?.message); + } + }, + [ + trackDescriptor, + progressInfo.pos, + isCurrentTrack, + ensureLoaded, + seekTrackBy, + ] + ); + + const handleLyricsSeek = useCallback( + async (timestampS) => { + if ( + !trackDescriptor || + typeof timestampS !== "number" || + Number.isNaN(timestampS) + ) + return; + const targetMs = Math.max(0, Number(timestampS) * 1000); try { - const wasPlaying = !!player?.playing; - await player.seekTo?.(bounded); - setProgressInfo((prev) => ({ ...prev, pos: bounded * 1000 })); - if (wasPlaying) { - if (!player?.playing) { - await player.play?.(); - } - setIsPlaying(true); + if (!isCurrentTrack) { + await ensureLoaded({ startPositionMs: targetMs, autoPlay: true }); + return; + } + await seekTrackTo(targetMs); + if (!isTrackPlaying) { + await resumeTrack(); } } catch (e) { console.log("MusicDetails lyrics seek error", e?.message); } }, - [player] + [ + trackDescriptor, + isCurrentTrack, + ensureLoaded, + seekTrackTo, + isTrackPlaying, + resumeTrack, + ] ); const description = useMemo(() => { // Build a readable text from lyrics with section labels @@ -566,39 +661,19 @@ const MusicDetails = ({ route }) => { maxValue={fmt(progressInfo.dur)} progress={ progressInfo.dur - ? (progressInfo.pos || 0) / progressInfo.dur + ? Math.min(1, Math.max(0, (progressInfo.pos || 0) / progressInfo.dur)) : 0 } seekEnabled={!!songUrl} - onSeekStart={async () => { - try { - wasPlayingBeforeSeek.current = !!player?.playing; - if (player?.playing) { - await player.pause?.(); - setIsPlaying(false); - } - } catch (e) { - console.log("Pause on seek start error", e?.message); - } - }} - onSeek={onSeek} - onSeekEnd={async () => { - try { - if (player && wasPlayingBeforeSeek.current) { - await player.play?.(); - setIsPlaying(true); - } - wasPlayingBeforeSeek.current = false; - } catch (e) { - console.log("Resume after seek error", e?.message); - } - }} + onSeekStart={handleSliderSeekStart} + onSeek={handleSliderSeek} + onSeekEnd={handleSliderSeekEnd} /> {/* Previous (rewind 10s) */} - seekBy(-10)}> + handleSeekBySeconds(-10)}> { alignItems: "center", justifyContent: "center", }} - onPress={togglePlay} + onPress={handleTogglePlay} > {/* Next (forward 10s) */} - seekBy(10)}> + handleSeekBySeconds(10)}>