Files
musicland/src/providers/PlayerProvider.js
T
Thomas Demirdjian d7b14bd873 clear last tickets
2025-11-26 15:33:22 +01:00

828 lines
21 KiB
JavaScript

import { useAudioPlayer, useAudioPlayerStatus } from "expo-audio";
import React, {
createContext,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Platform } from "react-native";
import { useGlobal } from "reactn";
import GlobalAudioPlayer from "../components/player/GlobalAudioPlayer";
import { Routes } from "../navigation/Routes";
const HIDDEN_ROUTE_NAMES = new Set([
Routes.Writing,
Routes.WritingLyrics,
Routes.CreateLyricsWithAi,
Routes.Lyrics,
Routes.FinishedWriting,
Routes.Studio,
Routes.Compose,
Routes.ComposeSong,
Routes.CustomizeVoice,
Routes.SongReady,
Routes.Regenerate,
Routes.PouchReady,
Routes.PhotoCover,
Routes.ValidateCover,
Routes.SongDownload,
Routes.FinishCompose,
Routes.GeneratingSong,
Routes.ProductionOnboarding,
Routes.Production,
Routes.PlaybackDownload,
Routes.DownloadPrices,
Routes.SongDownloaded,
Routes.StreamSong,
Routes.SongRelease,
Routes.PlaybackExample,
Routes.PlaybackOnboarding,
Routes.Playback,
Routes.RecordPlayback,
Routes.RecordedPlayback,
Routes.ChooseDecor,
Routes.CreatingDecor,
Routes.VideoFinalize,
Routes.FlowSelection,
Routes.ChooseCoverType,
Routes.Settings,
Routes.ProjectSettings,
Routes.AccountSettings,
Routes.ChangeEmailAddress,
Routes.ChangePassword,
Routes.Notifications,
Routes.Language,
Routes.Payments,
Routes.Login,
Routes.ResetPassword,
Routes.Register,
]);
const noopAsync = async () => { };
const noop = () => { };
const DEFAULT_CONTEXT = {
currentTrack: null,
queue: [],
queueInfo: null,
queueIndex: -1,
status: "idle",
isPlaying: false,
isBuffering: false,
positionMs: 0,
durationMs: 0,
error: null,
isPlayerVisible: true,
play: noopAsync,
togglePlay: noopAsync,
pause: noopAsync,
resume: noopAsync,
seekTo: noopAsync,
seekBy: noopAsync,
stop: noopAsync,
setQueue: noop,
isLooping: false,
setLooping: noop,
toggleLooping: noop,
getTrackInfo: () => null,
};
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 [activeRouteName] = useGlobal("activeRouteName");
const [currentTrack, setCurrentTrack] = useState(null);
const queueRef = useRef([]);
const queueInfoRef = useRef({ id: null, type: null, name: null });
const queueIndexRef = useRef(-1);
const [queue, setQueueState] = useState([]);
const [queueInfo, setQueueInfoState] = useState(queueInfoRef.current);
const [queueIndex, setQueueIndexState] = useState(queueIndexRef.current);
const [playback, setPlayback] = useState({
status: "idle",
isPlaying: false,
isBuffering: false,
positionMs: 0,
durationMs: 0,
});
const [error, setError] = useState(null);
const [isLooping, setIsLooping] = useState(false);
const autoPlayRef = useRef(false);
const pendingSeekValueRef = useRef(null);
const didJustFinishRef = useRef(false);
const trackInfoRef = useRef({});
const setQueueIndexValue = useCallback((index = -1) => {
const list = queueRef.current;
const hasItems = Array.isArray(list) && list.length > 0;
let target = Number.isInteger(index) ? index : queueIndexRef.current;
if (!hasItems) {
target = -1;
} else {
target = Math.min(Math.max(target, -1), list.length - 1);
}
if (queueIndexRef.current === target) return;
queueIndexRef.current = target;
setQueueIndexState(target);
}, []);
const setQueueInfoValue = useCallback((info) => {
const next = {
id: info?.id ?? null,
type: info?.type ?? null,
name: info?.name ?? null,
};
const prev = queueInfoRef.current;
if (
prev.id === next.id &&
prev.type === next.type &&
prev.name === next.name
) {
return;
}
queueInfoRef.current = next;
setQueueInfoState(next);
}, []);
const updateQueue = useCallback(
(items = [], options = {}) => {
const normalized = Array.isArray(items)
? items
.map((item) => {
if (!item) return null;
if (typeof item === "object") {
const { metadata, context, ...rest } = item;
const safeMetadata =
metadata && typeof metadata === "object"
? { ...metadata }
: undefined;
if (safeMetadata) {
delete safeMetadata.queue;
}
const safeContext =
context && typeof context === "object"
? { ...context }
: undefined;
if (safeContext) {
delete safeContext.queue;
}
return normalizeTrack(
{
...rest,
...(safeMetadata ? { metadata: safeMetadata } : {}),
...(safeContext ? { context: safeContext } : {}),
},
{}
);
}
return normalizeTrack(item, {});
})
.filter((track) => !!track?.source)
: [];
queueRef.current = normalized;
setQueueState(normalized);
const info = {
id: options.id ?? options.queueId ?? null,
type: options.type ?? options.queueType ?? null,
name: options.name ?? options.queueName ?? null,
};
setQueueInfoValue(info);
const currentTrackId =
options.currentTrackId && typeof options.currentTrackId === "string"
? options.currentTrackId
: null;
const indexCandidate =
typeof options.index === "number"
? options.index
: typeof options.queueIndex === "number"
? options.queueIndex
: currentTrackId
? normalized.findIndex((track) => track.id === currentTrackId)
: queueIndexRef.current;
setQueueIndexValue(indexCandidate);
return normalized;
},
[setQueueIndexValue, setQueueInfoValue]
);
const setQueue = useCallback(
(itemsOrConfig, maybeOptions = {}) => {
if (typeof itemsOrConfig === "function") {
const result = itemsOrConfig([...(queueRef.current || [])]);
if (Array.isArray(result)) {
updateQueue(result, maybeOptions);
}
return;
}
if (Array.isArray(itemsOrConfig)) {
updateQueue(itemsOrConfig, maybeOptions);
return;
}
if (itemsOrConfig && typeof itemsOrConfig === "object") {
const { items = [], ...rest } = itemsOrConfig;
updateQueue(items, { ...rest, ...maybeOptions });
return;
}
updateQueue([], {});
},
[updateQueue]
);
const source = useMemo(() => {
if (!currentTrack?.source) return null;
return currentTrack.source;
}, [currentTrack?.source]);
const player = useAudioPlayer(source || null, 200);
const status = useAudioPlayerStatus(player);
const shouldHideOnRoute = activeRouteName
? HIDDEN_ROUTE_NAMES.has(activeRouteName)
: false;
const toPlayerSeekValue = useCallback((milliseconds) => {
const ms = Math.max(0, Number(milliseconds) || 0);
return Platform.OS === "web" ? ms : ms / 1000;
}, []);
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,
};
});
if (currentTrack?.id) {
trackInfoRef.current[currentTrack.id] = {
durationMs:
durationMs > 0
? durationMs
: trackInfoRef.current[currentTrack.id]?.durationMs || 0,
positionMs,
isLoaded,
updatedAt: Date.now(),
};
}
}, [status, currentTrack]);
useEffect(() => {
if (!player || !currentTrack) return;
const run = async () => {
try {
const pendingSeek = pendingSeekValueRef.current;
pendingSeekValueRef.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]);
useEffect(() => {
if (!player) return;
try {
player.loop = !!isLooping;
} catch (_err) { }
}, [player, isLooping]);
const seekDebounceRef = useRef(null);
const pendingSeekPosRef = useRef(null);
const isSeekingRef = useRef(false);
const waitForActiveSeek = useCallback(async () => {
if (!isSeekingRef.current) return;
// Poll every 50ms until seek is done
while (isSeekingRef.current) {
await new Promise((resolve) => setTimeout(resolve, 50));
}
}, []);
const seekTo = useCallback(
async (positionMs) => {
if (!player) return;
const bounded = Math.max(0, Number(positionMs) || 0);
const seekValue = toPlayerSeekValue(bounded);
// Cancel any pending debounce
if (seekDebounceRef.current) {
clearTimeout(seekDebounceRef.current);
seekDebounceRef.current = null;
}
// Store pending seek position
pendingSeekPosRef.current = seekValue;
// Update local state immediately for UI responsiveness
setPlayback((prev) => ({
...prev,
positionMs: bounded,
}));
return new Promise((resolve) => {
seekDebounceRef.current = setTimeout(async () => {
try {
isSeekingRef.current = true;
if (status?.isLoaded) {
await player.seekTo?.(seekValue);
} else {
pendingSeekValueRef.current = seekValue;
}
} catch (err) {
setError(err);
} finally {
isSeekingRef.current = false;
pendingSeekPosRef.current = null;
resolve();
}
}, 100); // 100ms debounce
});
},
[player, status?.isLoaded, toPlayerSeekValue]
);
const seekBy = useCallback(
async (deltaMs) => {
const currentPos = pendingSeekPosRef.current !== null
? (Platform.OS === "web" ? pendingSeekPosRef.current : pendingSeekPosRef.current * 1000)
: playback.positionMs;
const next = Math.max(0, currentPos + Number(deltaMs || 0));
await seekTo(next);
},
[playback.positionMs, seekTo]
);
// Helper to flush pending seek before playing
const flushPendingSeek = useCallback(async () => {
// 1. Cancel pending debounce and execute immediately
if (seekDebounceRef.current) {
clearTimeout(seekDebounceRef.current);
seekDebounceRef.current = null;
}
if (pendingSeekPosRef.current !== null) {
const seekValue = pendingSeekPosRef.current;
pendingSeekPosRef.current = null;
try {
isSeekingRef.current = true;
if (status?.isLoaded) {
await player.seekTo?.(seekValue);
} else {
pendingSeekValueRef.current = seekValue;
}
} catch (err) {
setError(err);
} finally {
isSeekingRef.current = false;
}
}
// 2. Wait for any active seek to complete
await waitForActiveSeek();
}, [player, status?.isLoaded, waitForActiveSeek]);
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 targetSeekValue = toPlayerSeekValue(targetPositionMs);
const shouldAutoPlay = options?.autoPlay !== false;
setError(null);
if (Array.isArray(options.queue)) {
updateQueue(options.queue, {
id: options.queueId,
type: options.queueType,
name: options.queueName,
queueIndex:
typeof options.queueIndex === "number"
? options.queueIndex
: undefined,
currentTrackId: normalized.id,
});
}
if (sameTrack) {
setCurrentTrack((prev) => ({ ...prev, ...normalized }));
try {
// Flush any pending seek first
await flushPendingSeek();
if (targetPositionMs > 0) {
await player?.seekTo?.(targetSeekValue);
}
if (shouldAutoPlay) {
await player?.play?.();
}
} catch (err) {
setError(err);
}
if (!shouldAutoPlay) {
setPlayback((prev) => ({
...prev,
status: "ready",
isPlaying: false,
positionMs: targetPositionMs,
}));
}
return;
}
try {
if (player?.playing) {
await player.pause?.();
}
} catch (err) {
setError(err);
}
autoPlayRef.current = shouldAutoPlay;
pendingSeekValueRef.current =
targetPositionMs > 0 ? targetSeekValue : null;
setPlayback((prev) => ({
...prev,
status: "loading",
isPlaying: false,
positionMs: targetPositionMs,
}));
setCurrentTrack(normalized);
},
[currentTrack?.id, player, toPlayerSeekValue, updateQueue, flushPendingSeek]
);
const resume = useCallback(async () => {
if (!currentTrack) return;
try {
await flushPendingSeek();
await player?.play?.();
} catch (err) {
setError(err);
}
}, [player, currentTrack, flushPendingSeek]);
const pause = useCallback(async () => {
try {
autoPlayRef.current = false;
pendingSeekValueRef.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 stop = useCallback(async () => {
try {
autoPlayRef.current = false;
pendingSeekValueRef.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 setLooping = useCallback((value) => {
setIsLooping(!!value);
}, []);
const toggleLooping = useCallback(() => {
setIsLooping((prev) => !prev);
}, []);
const getTrackInfo = useCallback((trackId) => {
if (!trackId) return null;
return trackInfoRef.current[trackId] || null;
}, []);
useEffect(() => {
if (!currentTrack?.id) {
if (queueIndexRef.current !== -1) {
setQueueIndexValue(-1);
}
return;
}
const idx = queueRef.current.findIndex((item) => item.id === currentTrack.id);
if (idx !== queueIndexRef.current) {
setQueueIndexValue(idx);
}
}, [currentTrack?.id, setQueueIndexValue]);
const handleTrackDidFinish = useCallback(() => {
if (isLooping) return;
const items = queueRef.current;
if (!Array.isArray(items) || items.length === 0) return;
let idx = queueIndexRef.current;
const currentId = currentTrack?.id ?? null;
if ((idx == null || idx < 0) && currentId) {
idx = items.findIndex((item) => item.id === currentId);
}
if (idx == null || idx < 0) return;
const nextIdx = idx + 1;
if (nextIdx >= items.length) return;
const nextTrack = items[nextIdx];
if (!nextTrack) return;
play(nextTrack, {
autoPlay: true,
startPositionMs: 0,
queueId: queueInfoRef.current.id,
queueType: queueInfoRef.current.type,
queueName: queueInfoRef.current.name,
}).catch((err) => {
setError(err);
});
}, [currentTrack?.id, isLooping, play]);
useEffect(() => {
const finished = !!status?.didJustFinish;
if (finished) {
if (!didJustFinishRef.current) {
didJustFinishRef.current = true;
handleTrackDidFinish();
}
} else {
didJustFinishRef.current = false;
}
}, [status?.didJustFinish, handleTrackDidFinish]);
const contextValue = useMemo(
() => ({
currentTrack,
queue,
queueInfo,
queueIndex,
status: playback.status,
isPlaying: playback.isPlaying,
isBuffering: playback.isBuffering,
positionMs: playback.positionMs,
durationMs: playback.durationMs,
error,
isPlayerVisible: !shouldHideOnRoute,
play,
togglePlay,
pause,
resume,
seekTo,
seekBy,
stop,
setQueue,
isLooping,
setLooping,
toggleLooping,
getTrackInfo,
}),
[
currentTrack,
error,
pause,
play,
playback.durationMs,
playback.isBuffering,
playback.isPlaying,
playback.positionMs,
playback.status,
queue,
queueInfo,
queueIndex,
resume,
seekBy,
seekTo,
shouldHideOnRoute,
stop,
isLooping,
setLooping,
toggleLooping,
setQueue,
getTrackInfo,
]
);
return (
<PlayerContext.Provider value={contextValue}>
{children}
{!shouldHideOnRoute && <GlobalAudioPlayer />}
</PlayerContext.Provider>
);
};
export default PlayerProvider;