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