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 });