diff --git a/src/components/player/GlobalAudioPlayer.js b/src/components/player/GlobalAudioPlayer.js new file mode 100644 index 0000000..5e53590 --- /dev/null +++ b/src/components/player/GlobalAudioPlayer.js @@ -0,0 +1,194 @@ +import { BlurView } from "expo-blur"; +import React, { useMemo } from "react"; +import { + Image, + Platform, + Pressable, + StyleSheet, + Text, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { icons, img } from "../../assets"; +import usePlayer from "../../hooks/usePlayer"; +import { Palette } from "../../styles"; +import { FONT_FAMILY } from "../../styles/Fonts"; + +const formatDuration = (ms) => { + const totalSeconds = Math.max(0, Math.floor((ms || 0) / 1000)); + const minutes = Math.floor(totalSeconds / 60) + .toString() + .padStart(1, "0"); + const seconds = (totalSeconds % 60).toString().padStart(2, "0"); + return `${minutes}:${seconds}`; +}; + +const GlobalAudioPlayer = () => { + const { currentTrack, isPlaying, positionMs, durationMs, pause, resume } = + usePlayer(); + + const insets = useSafeAreaInsets(); + + const hasTrack = !!currentTrack?.source; + if (!hasTrack) return null; + + const title = currentTrack?.title || "Lecture en cours"; + const artist = currentTrack?.artist || ""; + const progress = + durationMs > 0 ? Math.min(1, Math.max(0, positionMs / durationMs)) : 0; + + const artworkSource = useMemo(() => { + if (typeof currentTrack?.artwork === "number") return currentTrack.artwork; + if (currentTrack?.artwork && typeof currentTrack.artwork === "string") { + return { uri: currentTrack.artwork }; + } + if (currentTrack?.coverUrl && typeof currentTrack.coverUrl === "string") { + return { uri: currentTrack.coverUrl }; + } + return img.placeholder; + }, [currentTrack?.artwork, currentTrack?.coverUrl]); + + const handleToggle = async () => { + if (isPlaying) { + await pause(); + } else { + await resume(); + } + }; + + return ( + + + + + + {title} + + {!!artist && ( + + {artist} + + )} + + + + + + {formatDuration(positionMs)} + {formatDuration(durationMs)} + + + + + + + + + + + ); +}; + +const styles = StyleSheet.create({ + host: { + position: "absolute", + left: 0, + right: 0, + bottom: 0, + pointerEvents: "box-none", + }, + container: { + marginHorizontal: 16, + marginBottom: 12, + borderRadius: 20, + flexDirection: "row", + alignItems: "center", + gap: 12, + paddingHorizontal: 16, + paddingTop: 12, + backgroundColor: "rgba(18, 11, 29, 0.85)", + borderWidth: 1, + borderColor: "#372152", + }, + cover: { + width: 48, + height: 48, + borderRadius: 12, + backgroundColor: "#1f1829", + }, + details: { + flex: 1, + gap: 6, + }, + title: { + color: Palette.white, + fontFamily: FONT_FAMILY.InterSemiBold, + fontSize: 14, + }, + artist: { + color: "#C2B7D6", + fontFamily: FONT_FAMILY.InterRegular, + fontSize: 12, + }, + progressContainer: { + gap: 6, + }, + progressTrack: { + height: 4, + borderRadius: 8, + backgroundColor: "rgba(255,255,255,0.1)", + overflow: "hidden", + }, + progressFill: { + height: 4, + backgroundColor: "#F94697", + }, + timeContainer: { + flexDirection: "row", + justifyContent: "space-between", + }, + time: { + color: "#C2B7D6", + fontFamily: FONT_FAMILY.InterRegular, + fontSize: 10, + }, + actions: { + flexDirection: "row", + alignItems: "center", + }, + toggleButton: { + width: 44, + height: 44, + borderRadius: 22, + alignItems: "center", + justifyContent: "center", + backgroundColor: "rgba(255, 255, 255, 0.1)", + }, + toggleIcon: { + width: 24, + height: 24, + tintColor: Palette.white, + resizeMode: "contain", + }, +}); + +export default GlobalAudioPlayer; diff --git a/src/hooks/usePlayer.js b/src/hooks/usePlayer.js new file mode 100644 index 0000000..7b26b7f --- /dev/null +++ b/src/hooks/usePlayer.js @@ -0,0 +1,9 @@ +import { useContext } from "react"; +import { PlayerContext } from "../providers/PlayerProvider"; + +const usePlayer = () => { + return useContext(PlayerContext); +}; + +export default usePlayer; + diff --git a/src/hooks/useSharedAudioPlayer.js b/src/hooks/useSharedAudioPlayer.js new file mode 100644 index 0000000..9f2e37a --- /dev/null +++ b/src/hooks/useSharedAudioPlayer.js @@ -0,0 +1,148 @@ +import { useEffect, useMemo, useRef } from "react"; +import useTrackController from "./useTrackController"; + +const buildDescriptorFromSource = (source) => { + if (!source) return null; + + const descriptor = {}; + if (typeof source === "number") { + descriptor.source = source; + descriptor.id = `asset-${source}`; + } else if (typeof source === "string") { + descriptor.uri = source; + descriptor.id = `uri-${source}`; + } else if (typeof source === "object") { + descriptor.source = source.source; + descriptor.uri = source.uri; + descriptor.assetId = source.assetId; + descriptor.headers = source.headers; + descriptor.id = source.id; + + if (!descriptor.id) { + if (typeof source.uri === "string") { + descriptor.id = `uri-${source.uri}`; + } else if (typeof source.assetId === "number") { + descriptor.id = `asset-${source.assetId}`; + } + } + } + + if (!descriptor.id) { + try { + descriptor.id = `src-${JSON.stringify(source)}`; + } catch (e) { + descriptor.id = `src-${Date.now()}`; + } + } + + if (!descriptor.uri && !descriptor.source && typeof descriptor.assetId === "number") { + descriptor.source = descriptor.assetId; + } + + if (!descriptor.uri && !descriptor.source) return null; + return descriptor; +}; + +const useSharedAudioPlayer = (source, options = null) => { + const descriptor = useMemo(() => { + const base = buildDescriptorFromSource(source); + if (!base) return null; + if (!options || typeof options !== "object") return base; + + const extended = { ...base }; + if (options.id) extended.id = options.id; + if (options.title) extended.title = options.title; + if (options.artist) extended.artist = options.artist; + if (options.artwork) extended.artwork = options.artwork; + if (options.coverUrl) extended.coverUrl = options.coverUrl; + if (options.metadata) extended.metadata = options.metadata; + if (options.context) extended.context = options.context; + return extended; + }, [source, options]); + + const { + play, + pause, + resume, + seekTo, + isPlaying, + positionMs, + durationMs, + } = useTrackController(descriptor); + + const stateRef = useRef({ + isPlaying: false, + positionMs: 0, + durationMs: 0, + }); + + useEffect(() => { + stateRef.current = { + isPlaying, + positionMs, + durationMs, + }; + }, [isPlaying, positionMs, durationMs]); + + const controlsRef = useRef({ play, pause, resume, seekTo }); + useEffect(() => { + controlsRef.current = { play, pause, resume, seekTo }; + }, [play, pause, resume, seekTo]); + + const playerRef = useRef(null); + + useEffect(() => { + if (!descriptor) { + playerRef.current = null; + return; + } + + if (!playerRef.current || playerRef.current.__trackId !== descriptor.id) { + const player = {}; + Object.defineProperty(player, "__trackId", { + value: descriptor.id, + enumerable: false, + writable: false, + }); + + player.play = async () => { + const { positionMs: currentPosition } = stateRef.current; + await controlsRef.current.play({ startPositionMs: currentPosition }); + }; + + player.pause = async () => { + await controlsRef.current.pause(); + }; + + player.resume = async () => { + await controlsRef.current.resume(); + }; + + player.seekTo = async (seconds) => { + const target = Math.max(0, Number(seconds) || 0) * 1000; + await controlsRef.current.seekTo(target); + }; + + Object.defineProperties(player, { + playing: { + enumerable: true, + get: () => !!stateRef.current.isPlaying, + }, + duration: { + enumerable: true, + get: () => stateRef.current.durationMs / 1000, + }, + currentTime: { + enumerable: true, + get: () => stateRef.current.positionMs / 1000, + }, + }); + + playerRef.current = player; + } + }, [descriptor]); + + return playerRef.current; +}; + +export default useSharedAudioPlayer; diff --git a/src/hooks/useTrackController.js b/src/hooks/useTrackController.js new file mode 100644 index 0000000..e948ef7 --- /dev/null +++ b/src/hooks/useTrackController.js @@ -0,0 +1,143 @@ +import { useCallback, useMemo } from "react"; +import usePlayer from "./usePlayer"; + +const useTrackController = (descriptor) => { + const { + currentTrack, + play, + pause, + resume, + isPlaying, + positionMs, + durationMs, + seekTo, + seekBy, + } = usePlayer(); + + const normalizedDescriptor = useMemo(() => { + if (!descriptor || !descriptor.id) return null; + return descriptor; + }, [descriptor]); + + const trackId = normalizedDescriptor?.id || null; + const isCurrent = trackId ? currentTrack?.id === trackId : false; + const effectivePositionMs = isCurrent ? positionMs : 0; + const effectiveDurationMs = isCurrent ? durationMs : 0; + const effectiveIsPlaying = isCurrent && isPlaying; + + const ensureLoaded = useCallback( + async ({ startPositionMs = 0, autoPlay = true } = {}) => { + if (!normalizedDescriptor) return; + await play(normalizedDescriptor, { + startPositionMs, + autoPlay, + context: normalizedDescriptor.context, + }); + }, + [normalizedDescriptor, play] + ); + + const playNow = useCallback( + async ({ startPositionMs = 0 } = {}) => { + await ensureLoaded({ startPositionMs, autoPlay: true }); + }, + [ensureLoaded] + ); + + const pauseNow = useCallback(async () => { + if (isCurrent) { + await pause(); + } + }, [isCurrent, pause]); + + const resumeNow = useCallback( + async ({ startPositionMs } = {}) => { + if (!normalizedDescriptor) return; + if (!isCurrent) { + await ensureLoaded({ + startPositionMs: startPositionMs ?? effectivePositionMs, + autoPlay: true, + }); + return; + } + if (typeof startPositionMs === "number") { + await seekTo(Math.max(0, startPositionMs)); + } + await resume(); + }, + [ + normalizedDescriptor, + isCurrent, + ensureLoaded, + effectivePositionMs, + seekTo, + resume, + ] + ); + + const seekToPosition = useCallback( + async (targetMs, { autoPlay = false } = {}) => { + const bounded = Math.max(0, Number(targetMs) || 0); + if (!normalizedDescriptor) return; + if (!isCurrent) { + await ensureLoaded({ startPositionMs: bounded, autoPlay }); + return; + } + await seekTo(bounded); + if (autoPlay && !effectiveIsPlaying) { + await resume(); + } + }, + [ + normalizedDescriptor, + isCurrent, + ensureLoaded, + seekTo, + effectiveIsPlaying, + resume, + ] + ); + + const seekByDelta = useCallback( + async (deltaMs, { autoPlay = false } = {}) => { + const delta = Number(deltaMs || 0); + if (!normalizedDescriptor) return; + if (!isCurrent) { + const next = Math.max(0, effectivePositionMs + delta); + await ensureLoaded({ startPositionMs: next, autoPlay }); + return; + } + await seekBy(delta); + if (autoPlay && !effectiveIsPlaying) { + await resume(); + } + }, + [ + normalizedDescriptor, + isCurrent, + ensureLoaded, + effectivePositionMs, + seekBy, + effectiveIsPlaying, + resume, + ] + ); + + return { + trackId, + descriptor: normalizedDescriptor, + isCurrent, + isPlaying: effectiveIsPlaying, + positionMs: effectivePositionMs, + durationMs: effectiveDurationMs, + ensureLoaded, + play: playNow, + pause: pauseNow, + resume: resumeNow, + seekTo: seekToPosition, + seekBy: seekByDelta, + }; +}; + +export default useTrackController; + diff --git a/src/providers/PlayerProvider.js b/src/providers/PlayerProvider.js new file mode 100644 index 0000000..f6494ce --- /dev/null +++ b/src/providers/PlayerProvider.js @@ -0,0 +1,422 @@ +import React, { + createContext, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { Platform } from "react-native"; +import { useAudioPlayer, useAudioPlayerStatus } from "expo-audio"; + +import GlobalAudioPlayer from "../components/player/GlobalAudioPlayer"; + +const noopAsync = async () => {}; +const noop = () => {}; + +const DEFAULT_CONTEXT = { + currentTrack: null, + queue: [], + status: "idle", + isPlaying: false, + isBuffering: false, + positionMs: 0, + durationMs: 0, + error: null, + play: noopAsync, + togglePlay: noopAsync, + pause: noopAsync, + resume: noopAsync, + seekTo: noopAsync, + seekBy: noopAsync, + stop: noopAsync, + setQueue: noop, +}; + +export const PlayerContext = createContext(DEFAULT_CONTEXT); + +const convertToMs = (value) => { + const numeric = Number(value ?? 0); + if (!Number.isFinite(numeric) || numeric <= 0) return 0; + if (Platform.OS === "web") return numeric; + if (numeric > 100000) return numeric; + return numeric * 1000; +}; + +const ensureSource = (track = {}, options = {}) => { + const candidate = track?.source ?? options?.source ?? null; + if (typeof candidate === "number") return candidate; + if (candidate && typeof candidate === "object") return candidate; + if (typeof candidate === "string") return { uri: candidate }; + + const uriCandidate = + track?.uri ?? + track?.url ?? + track?.songUrl ?? + track?.audioUrl ?? + track?.musicUrl ?? + (typeof track === "string" ? track : null) ?? + options?.uri ?? + options?.url ?? + options?.songUrl ?? + options?.audioUrl ?? + null; + + if (typeof uriCandidate === "number") return uriCandidate; + if (uriCandidate && typeof uriCandidate === "object") return uriCandidate; + if (typeof uriCandidate === "string" && uriCandidate.length) { + return { uri: uriCandidate }; + } + return null; +}; + +const getTrackId = (track = {}, options = {}) => { + if (typeof track === "string") return track; + return ( + track?.id ?? + options?.id ?? + track?.projectId ?? + track?.songId ?? + track?.uri ?? + track?.url ?? + track?.songUrl ?? + track?.musicUrl ?? + options?.uri ?? + options?.url ?? + options?.songUrl ?? + null + ); +}; + +const normalizeTrack = (trackInput = {}, options = {}) => { + const track = + typeof trackInput === "string" + ? { uri: trackInput } + : trackInput + ? { ...trackInput } + : {}; + + const source = ensureSource(track, options); + + const explicitId = getTrackId(track, options); + let id = explicitId; + + if (!id && typeof source === "string") { + id = source; + } + if (!id && typeof source === "number") { + id = String(source); + } + if (!id && source && typeof source === "object" && typeof source.uri === "string") { + id = source.uri; + } + if (!id) { + id = `track-${Date.now()}`; + } + + const titleCandidate = + typeof track?.title === "string" + ? track.title + : typeof options?.title === "string" + ? options.title + : ""; + + const artistCandidate = + typeof track?.artist === "string" + ? track.artist + : typeof options?.artist === "string" + ? options.artist + : ""; + + const artwork = track?.artwork ?? track?.coverUrl ?? options?.artwork ?? options?.coverUrl ?? null; + + return { + id, + title: titleCandidate, + artist: artistCandidate, + artwork, + coverUrl: track?.coverUrl ?? options?.coverUrl ?? null, + source, + metadata: { + ...(options?.metadata || {}), + ...(track?.metadata || {}), + }, + context: options?.context ?? track?.context ?? null, + }; +}; + +const PlayerProvider = ({ children }) => { + const [currentTrack, setCurrentTrack] = useState(null); + const [queue, setQueue] = useState([]); + const [playback, setPlayback] = useState({ + status: "idle", + isPlaying: false, + isBuffering: false, + positionMs: 0, + durationMs: 0, + }); + const [error, setError] = useState(null); + + const autoPlayRef = useRef(false); + const pendingSeekSecondsRef = useRef(null); + + const source = useMemo(() => { + if (!currentTrack?.source) return null; + return currentTrack.source; + }, [currentTrack?.source]); + + const player = useAudioPlayer(source || null, 200); + const status = useAudioPlayerStatus(player); + + useEffect(() => { + if (!status) return; + + const durationMs = convertToMs(status.duration); + const positionMs = convertToMs(status.currentTime); + const isLoaded = !!status.isLoaded; + const isPlaying = !!status.playing; + const isBuffering = !!status.isBuffering; + const didJustFinish = !!status.didJustFinish; + + setPlayback((prev) => { + let nextStatus = prev.status; + if (!currentTrack) { + nextStatus = "idle"; + } else if (!isLoaded) { + nextStatus = "loading"; + } else if (didJustFinish) { + nextStatus = "ended"; + } else if (isPlaying) { + nextStatus = "playing"; + } else if (prev.status === "loading" && isLoaded) { + nextStatus = "ready"; + } else if (!isPlaying && isLoaded) { + nextStatus = "paused"; + } + + return { + status: nextStatus, + isPlaying, + isBuffering, + positionMs, + durationMs, + }; + }); + }, [status, currentTrack]); + + useEffect(() => { + if (!player || !currentTrack) return; + + const run = async () => { + try { + const pendingSeek = pendingSeekSecondsRef.current; + pendingSeekSecondsRef.current = null; + + if (typeof pendingSeek === "number" && pendingSeek >= 0) { + await player.seekTo?.(pendingSeek); + } + + if (autoPlayRef.current) { + autoPlayRef.current = false; + await player.play?.(); + } + } catch (err) { + setError(err); + } + }; + + run(); + }, [player, currentTrack?.id]); + + const play = useCallback( + async (trackInput, options = {}) => { + const normalized = normalizeTrack(trackInput, options); + if (!normalized.source) { + console.warn("PlayerProvider: impossible de lancer la lecture, source audio manquante"); + return; + } + + const sameTrack = currentTrack?.id && normalized.id === currentTrack.id; + const targetPositionMs = + typeof options?.startPositionMs === "number" + ? options.startPositionMs + : typeof options?.positionMs === "number" + ? options.positionMs + : 0; + const targetSeconds = Math.max(0, targetPositionMs) / 1000; + const shouldAutoPlay = options?.autoPlay !== false; + + setError(null); + + if (sameTrack) { + setCurrentTrack((prev) => ({ ...prev, ...normalized })); + try { + if (targetSeconds > 0) { + await player?.seekTo?.(targetSeconds); + } + if (shouldAutoPlay) { + await player?.play?.(); + } + } catch (err) { + setError(err); + } + if (!shouldAutoPlay) { + setPlayback((prev) => ({ + ...prev, + status: "ready", + isPlaying: false, + positionMs: targetPositionMs, + })); + } + return; + } + + autoPlayRef.current = shouldAutoPlay; + pendingSeekSecondsRef.current = targetSeconds > 0 ? targetSeconds : null; + + setPlayback((prev) => ({ + ...prev, + status: "loading", + isPlaying: false, + positionMs: targetPositionMs, + })); + setCurrentTrack(normalized); + }, + [currentTrack?.id, player] + ); + + const resume = useCallback(async () => { + if (!currentTrack) return; + try { + await player?.play?.(); + } catch (err) { + setError(err); + } + }, [player, currentTrack]); + + const pause = useCallback(async () => { + try { + autoPlayRef.current = false; + pendingSeekSecondsRef.current = null; + await player?.pause?.(); + } catch (err) { + setError(err); + } + }, [player]); + + const togglePlay = useCallback( + async (trackInput, options = {}) => { + if (!trackInput && !currentTrack) return; + const targetId = getTrackId( + typeof trackInput === "string" ? { uri: trackInput } : trackInput, + options + ); + + if (trackInput && (!currentTrack || (targetId && targetId !== currentTrack.id))) { + await play(trackInput, options); + return; + } + + if (playback.isPlaying) { + await pause(); + } else if (currentTrack) { + await resume(); + } else if (trackInput) { + await play(trackInput, options); + } + }, + [currentTrack, playback.isPlaying, pause, play, resume] + ); + + const seekTo = useCallback( + async (positionMs) => { + if (!player) return; + const bounded = Math.max(0, Number(positionMs) || 0); + const seconds = bounded / 1000; + + try { + if (status?.isLoaded) { + await player.seekTo?.(seconds); + } else { + pendingSeekSecondsRef.current = seconds; + } + } catch (err) { + setError(err); + } + }, + [player, status?.isLoaded] + ); + + const seekBy = useCallback( + async (deltaMs) => { + const next = Math.max(0, playback.positionMs + Number(deltaMs || 0)); + await seekTo(next); + }, + [playback.positionMs, seekTo] + ); + + const stop = useCallback(async () => { + try { + autoPlayRef.current = false; + pendingSeekSecondsRef.current = null; + await player?.pause?.(); + } catch (err) { + setError(err); + } finally { + setCurrentTrack(null); + setPlayback({ + status: "idle", + isPlaying: false, + isBuffering: false, + positionMs: 0, + durationMs: 0, + }); + } + }, [player]); + + const contextValue = useMemo( + () => ({ + currentTrack, + queue, + status: playback.status, + isPlaying: playback.isPlaying, + isBuffering: playback.isBuffering, + positionMs: playback.positionMs, + durationMs: playback.durationMs, + error, + play, + togglePlay, + pause, + resume, + seekTo, + seekBy, + stop, + setQueue, + }), + [ + currentTrack, + error, + pause, + play, + playback.durationMs, + playback.isBuffering, + playback.isPlaying, + playback.positionMs, + playback.status, + queue, + resume, + seekBy, + seekTo, + stop, + ] + ); + + return ( + + {children} + + + ); +}; + +export default PlayerProvider; diff --git a/src/providers/SharedProviders.js b/src/providers/SharedProviders.js index ee05fa2..dad50dd 100644 --- a/src/providers/SharedProviders.js +++ b/src/providers/SharedProviders.js @@ -4,6 +4,7 @@ import React from "react"; import { SafeAreaProvider } from "react-native-safe-area-context"; import { SheetProvider } from "react-native-actions-sheet"; +import PlayerProvider from "./PlayerProvider"; import SplashAnimationProvider from "./SplashAnimationProvider"; import StripeEmbeddedProvider from "./StripeEmbeddedProvider"; import UniversalLinkProvider from "./UniversalLinkProvider"; @@ -21,6 +22,7 @@ const SharedProviders = ({ children }) => { [UniversalLinkProvider, {}], [BottomSheetModalProvider, {}], [SheetProvider, {}], + [PlayerProvider, {}], ]; // Dynamically nest the providers using reduce diff --git a/src/screens/Library/MusicDetails.js b/src/screens/Library/MusicDetails.js index b5ed6a0..45d0ff8 100644 --- a/src/screens/Library/MusicDetails.js +++ b/src/screens/Library/MusicDetails.js @@ -1,4 +1,4 @@ -import { useAudioPlayer } from "expo-audio"; +import useSharedAudioPlayer from "../../hooks/useSharedAudioPlayer"; import { Image as ExpoImage } from "expo-image"; import React, { useEffect, useMemo, useRef, useState } from "react"; import { @@ -75,7 +75,14 @@ const MusicDetails = ({ route }) => { const coverUrl = project?.coverUrl || null; const songUrl = project?.songUrl || null; - const player = useAudioPlayer(songUrl ? { uri: songUrl } : undefined); + const player = useSharedAudioPlayer(songUrl ? { uri: songUrl } : undefined, { + id: projectId ? `project-${projectId}` : songUrl ? `song-${songUrl}` : undefined, + title, + artist, + artwork: coverUrl, + coverUrl, + metadata: { projectId, screen: "MusicDetails" }, + }); // Karaoke aligned words (from timestamps) const alignedWords = useMemo(() => { diff --git a/src/screens/Library/MusicDetails.web.js b/src/screens/Library/MusicDetails.web.js index 9fc3820..48a4406 100644 --- a/src/screens/Library/MusicDetails.web.js +++ b/src/screens/Library/MusicDetails.web.js @@ -1,6 +1,6 @@ import { BlurView } from "expo-blur"; import { Image as ExpoImage } from "expo-image"; -import React, { useEffect, useMemo, useRef, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Pressable, Image as RNImage, @@ -26,119 +26,12 @@ import { usersRef, } from "../../config/firebase"; import useDataFromRef from "../../hooks/useDataFromRef"; +import useSharedAudioPlayer from "../../hooks/useSharedAudioPlayer"; import Page from "../../layouts/Page"; import { Palette } from "../../styles"; import { FONT_FAMILY } from "../../styles/Fonts"; import Style, { gutters, size } from "../../styles/Style"; -const useHtmlAudioPlayer = (source) => { - const src = typeof source === "string" ? source : source?.uri || null; - const audioRef = useRef(null); - const [state, setState] = useState({ - durationMs: 0, - positionMs: 0, - isPlaying: false, - }); - - useEffect(() => { - setState({ durationMs: 0, positionMs: 0, isPlaying: false }); - - if (!src) { - if (audioRef.current) { - audioRef.current.pause(); - audioRef.current.src = ""; - audioRef.current.load(); - audioRef.current = null; - } - return undefined; - } - - const audio = new Audio(src); - audio.preload = "auto"; - audio.crossOrigin = "anonymous"; - audioRef.current = audio; - - const syncDuration = () => { - const duration = Number.isFinite(audio.duration) - ? audio.duration * 1000 - : 0; - setState((prev) => ({ ...prev, durationMs: duration })); - }; - - const handleLoadedMetadata = () => syncDuration(); - const handleDurationChange = () => syncDuration(); - const handleTimeUpdate = () => { - setState((prev) => ({ - ...prev, - positionMs: audio.currentTime * 1000, - })); - }; - const handlePlay = () => setState((prev) => ({ ...prev, isPlaying: true })); - const handlePause = () => - setState((prev) => ({ ...prev, isPlaying: false })); - const handleEnded = () => - setState((prev) => ({ - ...prev, - positionMs: Number.isFinite(audio.duration) - ? audio.duration * 1000 - : prev.positionMs, - isPlaying: false, - })); - - audio.addEventListener("loadedmetadata", handleLoadedMetadata); - audio.addEventListener("durationchange", handleDurationChange); - audio.addEventListener("timeupdate", handleTimeUpdate); - audio.addEventListener("play", handlePlay); - audio.addEventListener("pause", handlePause); - audio.addEventListener("ended", handleEnded); - - return () => { - audio.pause(); - audio.removeEventListener("loadedmetadata", handleLoadedMetadata); - audio.removeEventListener("durationchange", handleDurationChange); - audio.removeEventListener("timeupdate", handleTimeUpdate); - audio.removeEventListener("play", handlePlay); - audio.removeEventListener("pause", handlePause); - audio.removeEventListener("ended", handleEnded); - audio.src = ""; - audio.load(); - audioRef.current = null; - }; - }, [src]); - - const play = React.useCallback(async () => { - if (!audioRef.current) return; - try { - await audioRef.current.play(); - } catch (e) { - throw e; - } - }, []); - - const pause = React.useCallback(() => { - if (!audioRef.current) return; - audioRef.current.pause(); - }, []); - - const seekTo = React.useCallback((seconds) => { - if (!audioRef.current) return; - const next = Math.max(0, Number(seconds) || 0); - const duration = audioRef.current.duration; - const bounded = Number.isFinite(duration) ? Math.min(next, duration) : next; - audioRef.current.currentTime = bounded; - setState((prev) => ({ ...prev, positionMs: bounded * 1000 })); - }, []); - - return { - play, - pause, - seekTo, - playing: state.isPlaying, - positionMs: state.positionMs, - durationMs: state.durationMs, - }; -}; - // 20 secondes const timeBeforeIncrement = 20000; @@ -183,14 +76,29 @@ const MusicDetails = ({ route }) => { const coverUrl = project?.coverUrl || null; const songUrl = project?.songUrl || null; - const { - play: playAudio, - pause: pauseAudio, - seekTo: seekAudio, - playing: isPlaying, - positionMs, - durationMs, - } = useHtmlAudioPlayer(songUrl ? { uri: songUrl } : undefined); + 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); // Karaoke aligned words (from timestamps) const alignedWords = useMemo(() => { diff --git a/src/screens/Playback/RecordPlayback.js b/src/screens/Playback/RecordPlayback.js index 71b23e2..4ab2b22 100644 --- a/src/screens/Playback/RecordPlayback.js +++ b/src/screens/Playback/RecordPlayback.js @@ -1,5 +1,5 @@ import { useFocusEffect } from "@react-navigation/native"; -import { useAudioPlayer } from "expo-audio"; +import useSharedAudioPlayer from "../../hooks/useSharedAudioPlayer"; import { CameraView, useCameraPermissions } from "expo-camera"; import React, { useCallback, @@ -54,7 +54,14 @@ const RecordPlayback = ({ route }) => { // Musique const songUrl = project?.songUrl || null; - const player = useAudioPlayer(songUrl ? { uri: songUrl } : undefined); + const player = useSharedAudioPlayer(songUrl ? { uri: songUrl } : undefined, { + id: projectId ? `record-${projectId}` : songUrl ? `record-${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, screen: "RecordPlayback" }, + }); const musicIndex = useMemo(() => { const i = Number(songIndex); diff --git a/src/screens/Playback/RecordPlayback.web.js b/src/screens/Playback/RecordPlayback.web.js index f8881e1..a28f0ca 100644 --- a/src/screens/Playback/RecordPlayback.web.js +++ b/src/screens/Playback/RecordPlayback.web.js @@ -1,5 +1,5 @@ import { useFocusEffect } from "@react-navigation/native"; -import { useAudioPlayer } from "expo-audio"; +import useSharedAudioPlayer from "../../hooks/useSharedAudioPlayer"; import { useCameraPermissions } from "expo-camera"; import React, { useCallback, @@ -61,7 +61,14 @@ const RecordPlayback = ({ route }) => { // Player const songUrl = project?.songUrl || null; - const player = useAudioPlayer(songUrl ? { uri: songUrl } : undefined); + const player = useSharedAudioPlayer(songUrl ? { uri: songUrl } : undefined, { + id: projectId ? `record-${projectId}` : songUrl ? `record-${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, screen: "RecordPlayback" }, + }); // MediaStream / Recorder (web only) const previewVideoRef = useRef(null); diff --git a/src/screens/Playback/RecordedPlayback.js b/src/screens/Playback/RecordedPlayback.js index a45988c..be1de2b 100644 --- a/src/screens/Playback/RecordedPlayback.js +++ b/src/screens/Playback/RecordedPlayback.js @@ -1,4 +1,4 @@ -import { useAudioPlayer } from "expo-audio"; +import useSharedAudioPlayer from "../../hooks/useSharedAudioPlayer"; import * as FileSystem from "expo-file-system"; import { VideoView, useVideoPlayer } from "expo-video"; import React, { useEffect, useMemo, useRef, useState } from "react"; @@ -16,7 +16,14 @@ const RecordedPlayback = ({ route }) => { const { videoUri, project } = route.params || {}; const songUrl = project?.songUrl || null; console.log("video uri is : ", videoUri); - const audioPlayer = useAudioPlayer(songUrl ? { uri: songUrl } : undefined); + 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 diff --git a/src/screens/Playback/RecordedPlayback.web.js b/src/screens/Playback/RecordedPlayback.web.js index 521043d..4c9b1cc 100644 --- a/src/screens/Playback/RecordedPlayback.web.js +++ b/src/screens/Playback/RecordedPlayback.web.js @@ -1,4 +1,4 @@ -import { useAudioPlayer } from "expo-audio"; +import useSharedAudioPlayer from "../../hooks/useSharedAudioPlayer"; import React, { useEffect, useMemo, useRef, useState } from "react"; import { Text, View } from "react-native"; import { background } from "../../assets"; @@ -29,7 +29,14 @@ const RecordedPlayback = ({ route }) => { const songUrl = project?.songUrl || null; console.log("video uri is : ", videoUri); // AUDIO PLAYER (expo-audio → seconds) - const audioPlayer = useAudioPlayer(songUrl ? { uri: songUrl } : undefined); + 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" }, + }); // Optionnel: si tu veux quand même un rendu vidéo sur web si tu as une source const videoElRef = useRef(null); diff --git a/src/screens/Playbacks/Playbacks.js b/src/screens/Playbacks/Playbacks.js index 80412e2..07070b7 100644 --- a/src/screens/Playbacks/Playbacks.js +++ b/src/screens/Playbacks/Playbacks.js @@ -1,5 +1,5 @@ import { useIsFocused, useRoute } from "@react-navigation/native"; -import { useAudioPlayer } from "expo-audio"; +import useSharedAudioPlayer from "../../hooks/useSharedAudioPlayer"; import { BlurView } from "expo-blur"; import { VideoView, useVideoPlayer } from "expo-video"; import React, { @@ -115,7 +115,14 @@ const PlaybackItem = ({ item, isActive, userCache, getUserByUid }) => { const creatorName = item?.userName; - const audioPlayer = useAudioPlayer(audioSource || undefined); + const audioPlayer = useSharedAudioPlayer(audioSource || undefined, { + id: item?.id ? `playback-${item.id}` : audioSource?.uri ? `playback-${audioSource.uri}` : undefined, + title: typeof item?.title === "string" ? item.title : "", + artist: typeof item?.userName === "string" ? item.userName : "", + artwork: item?.coverUrl || null, + coverUrl: item?.coverUrl || null, + metadata: { playbackId: item?.id }, + }); const videoPlayer = useVideoPlayer(videoUrl || null, (p) => { p.loop = false; p.muted = true; diff --git a/src/screens/Playbacks/components/PlaybackItem.web.js b/src/screens/Playbacks/components/PlaybackItem.web.js index 43afa09..862560d 100644 --- a/src/screens/Playbacks/components/PlaybackItem.web.js +++ b/src/screens/Playbacks/components/PlaybackItem.web.js @@ -1,4 +1,4 @@ -import { useAudioPlayer } from "expo-audio"; +import useSharedAudioPlayer from "../../../hooks/useSharedAudioPlayer"; import { BlurView } from "expo-blur"; import React, { useCallback, @@ -144,7 +144,14 @@ const PlaybackItem = ({ setIsLiked(currentUID ? lb.includes(currentUID) : false); }, [item?.likedBy, currentUID]); - const audioPlayer = useAudioPlayer(audioSource || undefined); + const audioPlayer = useSharedAudioPlayer(audioSource || undefined, { + id: item?.id ? `playback-${item.id}` : audioSource?.uri ? `playback-${audioSource.uri}` : undefined, + title: typeof item?.title === "string" ? item.title : "", + artist: typeof item?.userName === "string" ? item.userName : "", + artwork: item?.coverUrl || null, + coverUrl: item?.coverUrl || null, + metadata: { playbackId: item?.id }, + }); // Helpers to normalize and map clocks const getAudioTimeSeconds = useCallback(() => { diff --git a/src/screens/Studio/SongReady.js b/src/screens/Studio/SongReady.js index d084b79..e2c9475 100644 --- a/src/screens/Studio/SongReady.js +++ b/src/screens/Studio/SongReady.js @@ -1,6 +1,6 @@ /* global setInterval, clearInterval */ import { useFocusEffect } from "@react-navigation/native"; -import { useAudioPlayer } from "expo-audio"; +import useSharedAudioPlayer from "../../hooks/useSharedAudioPlayer"; import { BlurView } from "expo-blur"; import React, { useCallback, useEffect, useRef, useState } from "react"; import { Image, Platform, Pressable, Text, View } from "react-native"; @@ -34,11 +34,31 @@ const SongReady = () => { 0: { pos: 0, dur: 0 }, 1: { pos: 0, dur: 0 }, }); - const player0 = useAudioPlayer( - musicUrls[0] ? { uri: musicUrls[0] } : undefined + const player0 = useSharedAudioPlayer( + musicUrls[0] ? { uri: musicUrls[0] } : undefined, + { + id: musicUrls[0] ? `songready-${musicUrls[0]}` : undefined, + title: + (Array.isArray(selectedProject?.musicTitles) + ? selectedProject?.musicTitles?.[0] + : null) || selectedProject?.title || "Option 1", + artwork: selectedProject?.coverUrl || null, + coverUrl: selectedProject?.coverUrl || null, + metadata: { index: 0, projectId }, + } ); - const player1 = useAudioPlayer( - musicUrls[1] ? { uri: musicUrls[1] } : undefined + const player1 = useSharedAudioPlayer( + musicUrls[1] ? { uri: musicUrls[1] } : undefined, + { + id: musicUrls[1] ? `songready-${musicUrls[1]}` : undefined, + title: + (Array.isArray(selectedProject?.musicTitles) + ? selectedProject?.musicTitles?.[1] + : null) || selectedProject?.title || "Option 2", + artwork: selectedProject?.coverUrl || null, + coverUrl: selectedProject?.coverUrl || null, + metadata: { index: 1, projectId }, + } ); const wasPlayingBeforeSeek = useRef({ 0: false, 1: false });