diff --git a/src/screens/Library/MusicDetails.web.js b/src/screens/Library/MusicDetails.web.js index 48a4406..15ece19 100644 --- a/src/screens/Library/MusicDetails.web.js +++ b/src/screens/Library/MusicDetails.web.js @@ -1,6 +1,12 @@ import { BlurView } from "expo-blur"; import { Image as ExpoImage } from "expo-image"; -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { Pressable, Image as RNImage, @@ -26,7 +32,7 @@ import { usersRef, } from "../../config/firebase"; import useDataFromRef from "../../hooks/useDataFromRef"; -import useSharedAudioPlayer from "../../hooks/useSharedAudioPlayer"; +import useTrackController from "../../hooks/useTrackController"; import Page from "../../layouts/Page"; import { Palette } from "../../styles"; import { FONT_FAMILY } from "../../styles/Fonts"; @@ -76,29 +82,40 @@ const MusicDetails = ({ route }) => { const coverUrl = project?.coverUrl || null; const songUrl = project?.songUrl || null; - const sharedPlayer = useSharedAudioPlayer(songUrl ? { uri: songUrl } : undefined, { - id: projectId ? `project-${projectId}` : songUrl ? `song-${songUrl}` : undefined, - title, - artist, - artwork: coverUrl, - coverUrl, - metadata: { projectId, screen: "MusicDetails" }, - }); - const playAudio = useCallback(async () => { - if (sharedPlayer?.play) await sharedPlayer.play(); - }, [sharedPlayer]); - const pauseAudio = useCallback(async () => { - if (sharedPlayer?.pause) await sharedPlayer.pause(); - }, [sharedPlayer]); - const seekAudio = useCallback( - (seconds) => { - if (sharedPlayer?.seekTo) sharedPlayer.seekTo(seconds); - }, - [sharedPlayer] - ); - const isPlaying = !!sharedPlayer?.playing; - const positionMs = Math.max(0, (sharedPlayer?.currentTime || 0) * 1000); - const durationMs = Math.max(0, (sharedPlayer?.duration || 0) * 1000); + 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 isPlaying = isTrackPlaying; // Karaoke aligned words (from timestamps) const alignedWords = useMemo(() => { @@ -120,7 +137,7 @@ const MusicDetails = ({ route }) => { useEffect(() => { listenedMsRef.current = 0; incrementDoneRef.current = false; - }, [songUrl]); + }, [trackId]); // Start/stop a timer to accumulate listened milliseconds while playing useEffect(() => { @@ -165,59 +182,157 @@ const MusicDetails = ({ route }) => { return `${m}:${s}`; }; - const togglePlay = async () => { - if (!songUrl) return; + const togglePlay = useCallback(async () => { + if (!trackDescriptor) return; try { - if (isPlaying) { - await pauseAudio(); + if (!isCurrentTrack) { + await ensureLoaded({ + startPositionMs: positionMs, + autoPlay: true, + }); + return; + } + if (isTrackPlaying) { + await pauseTrack(); } else { - await playAudio(); + await resumeTrack(); } } catch (e) { console.log("MusicDetails audio error", e?.message); } - }; + }, [ + trackDescriptor, + isCurrentTrack, + ensureLoaded, + positionMs, + isTrackPlaying, + pauseTrack, + resumeTrack, + ]); - const onSeek = async (ratio) => { + const handleSliderSeekStart = useCallback(async () => { + if (!trackDescriptor) return; try { - const dur = durationMs || 0; - const pos = Math.floor(dur * ratio); - if (dur > 0) { - await seekAudio(Math.floor((pos || 0) / 1000)); + wasPlayingBeforeSeek.current = isTrackPlaying; + if (!isCurrentTrack) { + await ensureLoaded({ + startPositionMs: positionMs, + autoPlay: false, + }); + } + if (isTrackPlaying) { + await pauseTrack(); } } catch (e) { - console.log("MusicDetails seek error", e?.message); + console.log("MusicDetails seek start error", e?.message); } - }; + }, [ + trackDescriptor, + isTrackPlaying, + isCurrentTrack, + ensureLoaded, + positionMs, + pauseTrack, + ]); - const seekBy = async (deltaSeconds) => { + const handleSliderSeek = useCallback( + async (ratio) => { + const dur = durationMs || 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); + } + }, + [ + trackDescriptor, + durationMs, + isCurrentTrack, + ensureLoaded, + seekTrackTo, + ] + ); + + const handleSliderSeekEnd = useCallback(async () => { try { - const cur = Math.floor((positionMs || 0) / 1000); - const next = Math.max(0, cur + deltaSeconds); - await seekAudio(next); + if (wasPlayingBeforeSeek.current) { + if (!isCurrentTrack) { + await ensureLoaded({ + startPositionMs: positionMs, + autoPlay: true, + }); + } else { + await resumeTrack(); + } + } } catch (e) { - console.log("MusicDetails seekBy error", e?.message); + console.log("MusicDetails seek end error", e?.message); + } finally { + wasPlayingBeforeSeek.current = false; } - }; + }, [ensureLoaded, isCurrentTrack, positionMs, resumeTrack]); - const handleLyricsSeek = React.useCallback( + const handleSeekBySeconds = useCallback( + async (deltaSeconds) => { + if (!trackDescriptor) return; + const deltaMs = Number(deltaSeconds || 0) * 1000; + const target = Math.max(0, (positionMs || 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, + positionMs, + isCurrentTrack, + ensureLoaded, + seekTrackBy, + ] + ); + + const handleLyricsSeek = useCallback( async (timestampS) => { - if (typeof timestampS !== "number" || Number.isNaN(timestampS)) return; - const seconds = Math.max(0, Number(timestampS) || 0); - const durationS = durationMs > 0 ? durationMs / 1000 : null; - const bounded = durationS ? Math.min(seconds, durationS) : seconds; + if ( + !trackDescriptor || + typeof timestampS !== "number" || + Number.isNaN(timestampS) + ) + return; + const targetMs = Math.max(0, Number(timestampS) * 1000); try { - const wasPlaying = isPlaying; - seekAudio(bounded); - if (wasPlaying) { - await playAudio(); + 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); } }, - [durationMs, isPlaying, playAudio, seekAudio] + [ + trackDescriptor, + isCurrentTrack, + ensureLoaded, + seekTrackTo, + isTrackPlaying, + resumeTrack, + ] ); const description = useMemo(() => { // Build a readable text from lyrics with section labels @@ -605,7 +720,7 @@ const MusicDetails = ({ route }) => { }} > {/* Previous (rewind 10s) */} - seekBy(-10)}> + handleSeekBySeconds(-10)}> { /> {/* Next (forward 10s) */} - seekBy(10)}> + handleSeekBySeconds(10)}> { maxValue={fmt(durationMs)} progress={durationMs ? (positionMs || 0) / durationMs : 0} seekEnabled={!!songUrl} - onSeekStart={async () => { - try { - wasPlayingBeforeSeek.current = !!isPlaying; - if (isPlaying) { - await pauseAudio(); - } - } catch (e) { - console.log("Pause on seek start error", e?.message); - } - }} - onSeek={onSeek} - onSeekEnd={async () => { - try { - if (wasPlayingBeforeSeek.current) { - await playAudio(); - } - wasPlayingBeforeSeek.current = false; - } catch (e) { - console.log("Resume after seek error", e?.message); - } - }} + onSeekStart={handleSliderSeekStart} + onSeek={handleSliderSeek} + onSeekEnd={handleSliderSeekEnd} /> )}