global player

This commit is contained in:
2025-10-07 09:51:38 +02:00
parent 5cd6741ae6
commit f796d91267
15 changed files with 1031 additions and 136 deletions
+422
View File
@@ -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 (
<PlayerContext.Provider value={contextValue}>
{children}
<GlobalAudioPlayer />
</PlayerContext.Provider>
);
};
export default PlayerProvider;