global player
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user